diff --git a/CMakeLists.txt b/CMakeLists.txt index 0312304ca..f13dbf210 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,6 +34,8 @@ option(SIREN_PYTHON_PACKAGE ) option(SIREN_WITH_MARLEY "Enable MARLEY support if available" ON) option(SIREN_REQUIRE_MARLEY "Fail configure if MARLEY requested but not found" OFF) +option(SIREN_WITH_PYTHIA8 "Enable Pythia8 support if available" OFF) +option(SIREN_REQUIRE_PYTHIA8 "Fail configure if Pythia8 requested but not found" OFF) # include cmake dependencies include(GNUInstallDirs) @@ -189,6 +191,10 @@ include(MARLEY) if(TARGET MARLEY) apply_siren_compile_options(MARLEY) endif() +include(PYTHIA8) +if(TARGET PYTHIA8) + apply_siren_compile_options(PYTHIA8) +endif() # load macros for googletest include(testing) diff --git a/cmake/Packages/PYTHIA8.cmake b/cmake/Packages/PYTHIA8.cmake new file mode 100644 index 000000000..5996dfc61 --- /dev/null +++ b/cmake/Packages/PYTHIA8.cmake @@ -0,0 +1,146 @@ +# cmake/Packages/PYTHIA8.cmake +# +# Optional Pythia8 (+ LHAPDF) hookup, modeled on MARLEY.cmake. +# +# Usage (top-level CMakeLists.txt): +# option(SIREN_WITH_PYTHIA8 "Enable Pythia8 support if available" OFF) +# option(SIREN_REQUIRE_PYTHIA8 "Fail configure if Pythia8 requested but not found" OFF) +# include(cmake/Packages/PYTHIA8.cmake) +# if(PYTHIA8_FOUND) +# target_link_libraries( PUBLIC PYTHIA8) +# target_compile_definitions( PUBLIC SIREN_HAS_PYTHIA8=1) +# endif() + +set(PYTHIA8_FOUND FALSE) + +if(DEFINED SIREN_WITH_PYTHIA8 AND NOT SIREN_WITH_PYTHIA8) + message(STATUS "Pythia8 support disabled (SIREN_WITH_PYTHIA8=OFF)") + return() +endif() + +set(_pythia8_ok TRUE) + +# ----------------------------- +# 1) Find Pythia8 installation prefix +# ----------------------------- +unset(PYTHIA8_PREFIX CACHE) +unset(PYTHIA8_PREFIX) + +if(DEFINED ENV{PYTHIA8_ROOT} AND NOT "$ENV{PYTHIA8_ROOT}" STREQUAL "") + set(PYTHIA8_PREFIX "$ENV{PYTHIA8_ROOT}") + message(STATUS "Using PYTHIA8_ROOT from environment: ${PYTHIA8_PREFIX}") +elseif(DEFINED PYTHIA8_DIR AND NOT "${PYTHIA8_DIR}" STREQUAL "") + set(PYTHIA8_PREFIX "${PYTHIA8_DIR}") + message(STATUS "Using PYTHIA8_DIR from cache/cmdline: ${PYTHIA8_PREFIX}") +else() + find_path(PYTHIA8_PREFIX + NAMES include/Pythia8/Pythia.h + PATHS ${CMAKE_PREFIX_PATH} /usr /usr/local /opt/local /opt/homebrew + DOC "Root directory for Pythia8 installation" + ) +endif() + +if(NOT PYTHIA8_PREFIX) + set(_pythia8_ok FALSE) + message(STATUS "Pythia8 not found (no PYTHIA8_ROOT/PYTHIA8_DIR and not in CMAKE_PREFIX_PATH).") +endif() + +# ----------------------------- +# 2) Headers +# ----------------------------- +if(_pythia8_ok) + set(PYTHIA8_INCLUDE_DIR "${PYTHIA8_PREFIX}/include") + if(NOT EXISTS "${PYTHIA8_INCLUDE_DIR}/Pythia8/Pythia.h") + set(_pythia8_ok FALSE) + message(STATUS "Pythia8 headers not found at: ${PYTHIA8_INCLUDE_DIR}/Pythia8/Pythia.h") + endif() +endif() + +# ----------------------------- +# 3) Library +# ----------------------------- +if(_pythia8_ok) + find_library(PYTHIA8_LIBRARY + NAMES pythia8 Pythia8 + PATHS "${PYTHIA8_PREFIX}/lib64" "${PYTHIA8_PREFIX}/lib" + NO_DEFAULT_PATH + DOC "Pythia8 library" + ) + + if(NOT PYTHIA8_LIBRARY) + set(_pythia8_ok FALSE) + message(STATUS "Pythia8 library not found under ${PYTHIA8_PREFIX}/lib{,64}.") + endif() +endif() + +# ----------------------------- +# 4) Optional LHAPDF (PythiaDISCrossSection uses LHAPDF6 PDF set) +# ----------------------------- +set(_lhapdf_ok TRUE) +unset(LHAPDF_PREFIX CACHE) +unset(LHAPDF_PREFIX) + +if(_pythia8_ok) + if(DEFINED ENV{LHAPDF_ROOT} AND NOT "$ENV{LHAPDF_ROOT}" STREQUAL "") + set(LHAPDF_PREFIX "$ENV{LHAPDF_ROOT}") + message(STATUS "Using LHAPDF_ROOT from environment: ${LHAPDF_PREFIX}") + elseif(DEFINED LHAPDF_DIR AND NOT "${LHAPDF_DIR}" STREQUAL "") + set(LHAPDF_PREFIX "${LHAPDF_DIR}") + message(STATUS "Using LHAPDF_DIR from cache/cmdline: ${LHAPDF_PREFIX}") + else() + find_path(LHAPDF_PREFIX + NAMES include/LHAPDF/LHAPDF.h + PATHS ${CMAKE_PREFIX_PATH} /usr /usr/local /opt/local /opt/homebrew + DOC "Root directory for LHAPDF installation" + ) + endif() + + if(NOT LHAPDF_PREFIX) + set(_lhapdf_ok FALSE) + message(STATUS "LHAPDF not found (no LHAPDF_ROOT/LHAPDF_DIR and not in CMAKE_PREFIX_PATH); required for Pythia8 DIS support.") + else() + set(LHAPDF_INCLUDE_DIR "${LHAPDF_PREFIX}/include") + find_library(LHAPDF_LIBRARY + NAMES LHAPDF + PATHS "${LHAPDF_PREFIX}/lib64" "${LHAPDF_PREFIX}/lib" + NO_DEFAULT_PATH + DOC "LHAPDF library" + ) + if(NOT LHAPDF_LIBRARY) + set(_lhapdf_ok FALSE) + message(STATUS "LHAPDF library not found under ${LHAPDF_PREFIX}/lib{,64}.") + endif() + endif() + + if(NOT _lhapdf_ok) + set(_pythia8_ok FALSE) + endif() +endif() + +# ----------------------------- +# 5) Create imported target "PYTHIA8" (carries Pythia8 + LHAPDF link/include) +# ----------------------------- +if(_pythia8_ok) + if(NOT TARGET PYTHIA8) + add_library(PYTHIA8 UNKNOWN IMPORTED) + message(STATUS "Created imported target: PYTHIA8") + endif() + + set_target_properties(PYTHIA8 PROPERTIES + IMPORTED_LOCATION "${PYTHIA8_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${PYTHIA8_INCLUDE_DIR};${LHAPDF_INCLUDE_DIR}" + INTERFACE_LINK_LIBRARIES "${LHAPDF_LIBRARY};dl;pthread" + ) + + set(PYTHIA8_FOUND TRUE) + message(STATUS "Pythia8 enabled: prefix=${PYTHIA8_PREFIX} (LHAPDF prefix=${LHAPDF_PREFIX})") +endif() + +# ----------------------------- +# 6) Optional hard requirement behavior +# ----------------------------- +if(NOT PYTHIA8_FOUND) + if(DEFINED SIREN_REQUIRE_PYTHIA8 AND SIREN_REQUIRE_PYTHIA8) + message(FATAL_ERROR "SIREN_REQUIRE_PYTHIA8=ON but Pythia8 could not be found.") + endif() +endif() diff --git a/cmake/testing.cmake b/cmake/testing.cmake index 80097c2a6..bc6013961 100644 --- a/cmake/testing.cmake +++ b/cmake/testing.cmake @@ -10,13 +10,17 @@ set_target_properties(gtest_main PROPERTIES FOLDER extern) set_target_properties(gmock PROPERTIES FOLDER extern) set_target_properties(gmock_main PROPERTIES FOLDER extern) -if(${CIBUILDWHEEL}) +if(CIBUILDWHEEL) macro(package_add_test TESTNAME) endmacro() else() macro(package_add_test TESTNAME) add_executable(${TESTNAME} ${ARGN}) - target_link_libraries(${TESTNAME} gtest gmock gtest_main SIREN) + # GCC < 9 keeps std::filesystem in a separate library (libstdc++fs) + # that must be linked explicitly; some tests (e.g. GDMLParser) use it + # directly. Newer GCC and Clang fold it into the standard library. + target_link_libraries(${TESTNAME} gtest gmock gtest_main SIREN + $<$,$,9.0>>:stdc++fs>) add_dependencies(${TESTNAME} rk_static) add_test(NAME ${TESTNAME} COMMAND ${TESTNAME} WORKING_DIRECTORY ${PROJECT_BINARY_DIR}) set_target_properties(${TESTNAME} PROPERTIES FOLDER tests) diff --git a/projects/dataclasses/private/Particle.cxx b/projects/dataclasses/private/Particle.cxx index e970ccb84..154be3282 100644 --- a/projects/dataclasses/private/Particle.cxx +++ b/projects/dataclasses/private/Particle.cxx @@ -96,5 +96,12 @@ bool isCharged(ParticleType p){ p==ParticleType::Hadrons); } + +bool isD(Particle::ParticleType p){ + return(p==Particle::ParticleType::D0 || p==Particle::ParticleType::D0Bar || + p==Particle::ParticleType::DPlus || p==Particle::ParticleType::DMinus || + p==Particle::ParticleType::DsPlus || p==Particle::ParticleType::DsMinus); + } + } // namespace utilities } // namespace siren diff --git a/projects/dataclasses/public/SIREN/dataclasses/Particle.h b/projects/dataclasses/public/SIREN/dataclasses/Particle.h index 6373a730d..c4f12f24f 100644 --- a/projects/dataclasses/public/SIREN/dataclasses/Particle.h +++ b/projects/dataclasses/public/SIREN/dataclasses/Particle.h @@ -78,6 +78,8 @@ class Particle { bool isLepton(ParticleType p); bool isCharged(ParticleType p); bool isNeutrino(ParticleType p); +bool isD(Particle::ParticleType p); + } // namespace dataclasses } // namespace siren diff --git a/projects/dataclasses/public/SIREN/dataclasses/ParticleTypes.def b/projects/dataclasses/public/SIREN/dataclasses/ParticleTypes.def index 7f0e166f4..30b1fd838 100644 --- a/projects/dataclasses/public/SIREN/dataclasses/ParticleTypes.def +++ b/projects/dataclasses/public/SIREN/dataclasses/ParticleTypes.def @@ -32,6 +32,8 @@ X(Neutron, 2112) X(PPlus, 2212) X(PMinus, -2212) X(K0_Short, 310) +X(K0, 311) +X(K0Bar, -311) X(Eta, 221) X(EtaPrime, 331) X(Omega, 223) diff --git a/projects/detector/CMakeLists.txt b/projects/detector/CMakeLists.txt index d6086f205..c471ddd0f 100644 --- a/projects/detector/CMakeLists.txt +++ b/projects/detector/CMakeLists.txt @@ -65,6 +65,7 @@ package_add_test(UnitTest_Axis ${PROJECT_SOURCE_DIR}/projects/detector/private/t package_add_test(UnitTest_DensityDistribution ${PROJECT_SOURCE_DIR}/projects/detector/private/test/DensityDistribution_TEST.cxx) package_add_test(UnitTest_Distribution1D ${PROJECT_SOURCE_DIR}/projects/detector/private/test/Distribution1D_TEST.cxx) package_add_test(UnitTest_DetectorModel ${PROJECT_SOURCE_DIR}/projects/detector/private/test/DetectorModel_TEST.cxx) +package_add_test(UnitTest_DetectorModelDegenerateDirection ${PROJECT_SOURCE_DIR}/projects/detector/private/test/DetectorModelDegenerateDirection_TEST.cxx) package_add_test(UnitTest_MaterialModel ${PROJECT_SOURCE_DIR}/projects/detector/private/test/MaterialModel_TEST.cxx) package_add_test(UnitTest_Path ${PROJECT_SOURCE_DIR}/projects/detector/private/test/Path_TEST.cxx) package_add_test(UnitTest_GDMLParser ${PROJECT_SOURCE_DIR}/projects/detector/private/test/GDMLParser_TEST.cxx) diff --git a/projects/detector/private/DetectorModel.cxx b/projects/detector/private/DetectorModel.cxx index e5e41ea71..9628136a7 100644 --- a/projects/detector/private/DetectorModel.cxx +++ b/projects/detector/private/DetectorModel.cxx @@ -923,7 +923,7 @@ double DetectorModel::GetInteractionDensity(Geometry::IntersectionList const & i std::vector const & total_cross_sections, double const & total_decay_length) const { Vector3D direction = p0 - intersections.position; - if(direction.magnitude() == 0) { + if(direction.magnitude() <= distance_threshold) { direction = intersections.direction; } else { direction.normalize(); @@ -1220,6 +1220,7 @@ double DetectorModel::GetInteractionDepthInCGS(Geometry::IntersectionList const std::vector const & targets, std::vector const & total_cross_sections, double const & total_decay_length) const { + if(p0 == p1) { return 0.0; } @@ -1232,8 +1233,8 @@ double DetectorModel::GetInteractionDepthInCGS(Geometry::IntersectionList const if(targets.empty()) { return distance / total_decay_length; // m / m --> dimensionless } - if(distance == 0.0) { - return 0.0; + if(distance <= distance_threshold) { + return distance / total_decay_length; } direction.normalize(); diff --git a/projects/detector/private/test/DetectorModelDegenerateDirection_TEST.cxx b/projects/detector/private/test/DetectorModelDegenerateDirection_TEST.cxx new file mode 100644 index 000000000..907a7101b --- /dev/null +++ b/projects/detector/private/test/DetectorModelDegenerateDirection_TEST.cxx @@ -0,0 +1,189 @@ + +// Degenerate trajectory-direction behavior for DetectorModel depth queries. +// DetectorModel computes direction as (p1 - p0); at Earth-scale coordinates a +// sub-micrometer separation cancels catastrophically (coincident points give the +// zero vector). All depth queries must stay finite, non-negative, and not abort. +// +// NOTE: the unit-direction assert (std::abs(1.0 - std::abs(dot)) < 1e-6) only +// fires in an assertions-enabled build (Debug / RelWithDebInfo), not under NDEBUG; +// the finite/non-NaN return-value asserts below lock in behavior in either build. + +#include +#include +#include + +#include + +#include "SIREN/geometry/Geometry.h" +#include "SIREN/detector/DetectorModel.h" +#include "SIREN/detector/Coordinates.h" +#include "SIREN/math/Vector3D.h" +#include "SIREN/dataclasses/Particle.h" + +using namespace siren::math; +using namespace siren::geometry; +using namespace siren::detector; +using namespace siren::dataclasses; + +namespace { + +// distance_threshold in DetectorModel is 1e-5 m; pick an offset well below it +// but large enough to be exactly representable next to an Earth-scale x value. +constexpr double kEarthScaleX = 6.371e6; // m, ~Earth radius +constexpr double kSubThresholdOffset = 1e-7; // m, << distance_threshold (1e-5) + +bool IsFinite(double x) { + return std::isfinite(x) && !std::isnan(x); +} + +} // namespace + +// Distinct but sub-threshold points with non-empty targets: must short-circuit +// to distance/total_decay_length instead of normalizing the corrupted direction. +TEST(DetectorModelDegenerateDirection, InteractionDepthSubThresholdWithTargets) +{ + DetectorModel A; // infinite vacuum sphere, no files + + Vector3D p0; + p0.SetCartesianCoordinates(kEarthScaleX, 0.0, 0.0); + Vector3D p1; + p1.SetCartesianCoordinates(kEarthScaleX + kSubThresholdOffset, 0.0, 0.0); + + // p0 != p1 (early guard must not fire), yet separation is below threshold. + ASSERT_TRUE(p0 != p1); + double distance = (p1 - p0).magnitude(); + ASSERT_GT(distance, 0.0); + ASSERT_LE(distance, 1e-5); + + std::vector targets = {ParticleType::Nucleon}; + std::vector total_cross_sections = {1.0e-27}; // cm^2, arbitrary + double total_decay_length = 1.0e3; // m, arbitrary finite, nonzero + + double depth = std::numeric_limits::quiet_NaN(); + // Must NOT abort on assert(std::abs(1.0 - std::abs(dot)) < 1e-6). + ASSERT_NO_THROW(depth = A.GetInteractionDepthInCGS( + DetectorPosition(p0), DetectorPosition(p1), + targets, total_cross_sections, total_decay_length)); + + EXPECT_TRUE(IsFinite(depth)) << "InteractionDepth must be finite, got " << depth; + + // Sub-threshold result is the decay-only limit (continuous with targets.empty()). + double expected = distance / total_decay_length; + EXPECT_NEAR(expected, depth, std::abs(expected) * 1e-9 + 1e-30); + EXPECT_GE(depth, 0.0); +} + +// With empty targets, GetInteractionDepthInCGS takes the decay-only branch. +// Confirm the sub-threshold result is continuous with that branch and finite. +TEST(DetectorModelDegenerateDirection, InteractionDepthSubThresholdDecayOnly) +{ + DetectorModel A; + + Vector3D p0; + p0.SetCartesianCoordinates(kEarthScaleX, 0.0, 0.0); + Vector3D p1; + p1.SetCartesianCoordinates(kEarthScaleX + kSubThresholdOffset, 0.0, 0.0); + + double distance = (p1 - p0).magnitude(); + std::vector targets; // empty: decay-only branch + std::vector total_cross_sections; + double total_decay_length = 2.5e2; + + double depth = std::numeric_limits::quiet_NaN(); + ASSERT_NO_THROW(depth = A.GetInteractionDepthInCGS( + DetectorPosition(p0), DetectorPosition(p1), + targets, total_cross_sections, total_decay_length)); + + EXPECT_TRUE(IsFinite(depth)); + EXPECT_NEAR(distance / total_decay_length, depth, + std::abs(distance / total_decay_length) * 1e-9 + 1e-30); +} + +// Column depth on a sub-threshold but distinct segment normalizes a tiny (~1e-7) +// direction; must complete without aborting and return finite, non-negative. +TEST(DetectorModelDegenerateDirection, ColumnDepthSubThresholdIsFinite) +{ + DetectorModel A; + + Vector3D p0; + p0.SetCartesianCoordinates(kEarthScaleX, 0.0, 0.0); + Vector3D p1; + p1.SetCartesianCoordinates(kEarthScaleX + kSubThresholdOffset, 0.0, 0.0); + + double cd = std::numeric_limits::quiet_NaN(); + ASSERT_NO_THROW(cd = A.GetColumnDepthInCGS(DetectorPosition(p0), DetectorPosition(p1))); + EXPECT_TRUE(IsFinite(cd)) << "ColumnDepth must be finite, got " << cd; + EXPECT_GE(cd, 0.0); +} + +// GetParticleColumnDepth on the same sub-threshold segment must also return +// finite per-target values without aborting. +TEST(DetectorModelDegenerateDirection, ParticleColumnDepthSubThresholdIsFinite) +{ + DetectorModel A; + + Vector3D p0; + p0.SetCartesianCoordinates(kEarthScaleX, 0.0, 0.0); + Vector3D p1; + p1.SetCartesianCoordinates(kEarthScaleX + kSubThresholdOffset, 0.0, 0.0); + + std::vector targets = {ParticleType::Nucleon, ParticleType::EMinus}; + + std::vector counts; + Geometry::IntersectionList intersections = A.GetIntersections( + DetectorPosition(p0), DetectorDirection(Vector3D(1.0, 0.0, 0.0))); + ASSERT_NO_THROW(counts = A.GetParticleColumnDepth( + intersections, DetectorPosition(p0), DetectorPosition(p1), targets)); + + ASSERT_EQ(targets.size(), counts.size()); + for(double c : counts) { + EXPECT_TRUE(IsFinite(c)) << "ParticleColumnDepth entry must be finite, got " << c; + EXPECT_GE(c, 0.0); + } +} + +// Exact coincidence (p0 == p1) at Earth-scale: (p1 - p0) is the zero vector. The +// early guard plus Vector3D::normalize()'s zero-length guard must keep every depth +// query at 0, finite, no NaN. +TEST(DetectorModelDegenerateDirection, CoincidentPointsReturnZeroFinite) +{ + DetectorModel A; + + Vector3D v; + v.SetCartesianCoordinates(kEarthScaleX, 1.2e6, -3.4e5); + + // Column depth between identical points is exactly zero. + double cd = std::numeric_limits::quiet_NaN(); + ASSERT_NO_THROW(cd = A.GetColumnDepthInCGS(DetectorPosition(v), DetectorPosition(v))); + EXPECT_TRUE(IsFinite(cd)); + EXPECT_DOUBLE_EQ(0.0, cd); + + // Interaction depth between identical points is exactly zero (early guard). + std::vector targets = {ParticleType::Nucleon}; + std::vector total_cross_sections = {1.0e-27}; + double total_decay_length = 1.0e3; + double depth = std::numeric_limits::quiet_NaN(); + ASSERT_NO_THROW(depth = A.GetInteractionDepthInCGS( + DetectorPosition(v), DetectorPosition(v), + targets, total_cross_sections, total_decay_length)); + EXPECT_TRUE(IsFinite(depth)); + EXPECT_DOUBLE_EQ(0.0, depth); + + // Particle column depth between identical points is all zeros. + Geometry::IntersectionList intersections = A.GetIntersections( + DetectorPosition(v), DetectorDirection(Vector3D(1.0, 0.0, 0.0))); + std::vector counts; + ASSERT_NO_THROW(counts = A.GetParticleColumnDepth( + intersections, DetectorPosition(v), DetectorPosition(v), targets)); + ASSERT_EQ(targets.size(), counts.size()); + for(double c : counts) { + EXPECT_TRUE(IsFinite(c)); + EXPECT_DOUBLE_EQ(0.0, c); + } +} + +int main(int argc, char** argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/projects/detector/public/SIREN/detector/DetectorModel.h b/projects/detector/public/SIREN/detector/DetectorModel.h index 4a9b60c49..ce5dec840 100644 --- a/projects/detector/public/SIREN/detector/DetectorModel.h +++ b/projects/detector/public/SIREN/detector/DetectorModel.h @@ -109,6 +109,9 @@ friend siren::detector::Path; DetectorSector & best_sector) const; public: + // Threshold below which a direction vector's magnitude is treated as zero + // for the purpose of falling back to the intersection direction. + constexpr static double distance_threshold = 1e-5; DetectorModel(); DetectorModel(std::string const & detector_model, std::string const & material_model); DetectorModel(std::string const & path, std::string const & detector_model, std::string const & material_model); diff --git a/projects/injection/CMakeLists.txt b/projects/injection/CMakeLists.txt index dc2cf597b..1d96cb8de 100644 --- a/projects/injection/CMakeLists.txt +++ b/projects/injection/CMakeLists.txt @@ -40,6 +40,16 @@ install(DIRECTORY "${PROJECT_SOURCE_DIR}/projects/injection/public/" #package_add_test(UnitTest_Injector ${PROJECT_SOURCE_DIR}/projects/injection/private/test/Injector_TEST.cxx) package_add_test(UnitTest_Weighter ${PROJECT_SOURCE_DIR}/projects/injection/private/test/Weighter_TEST.cxx) +# Skip under cibuildwheel: package_add_test is a no-op there, so the following +# target_link_libraries would reference a target that was never created. +# Use plain `NOT CIBUILDWHEEL` (not `NOT ${CIBUILDWHEEL}`) so the empty-string +# local case evaluates true and the test still builds/runs in local ctest. +if(NOT CIBUILDWHEEL) +package_add_test(UnitTest_CharmSerialization ${PROJECT_SOURCE_DIR}/projects/injection/private/test/CharmSerialization_TEST.cxx) +# The injection headers pull in Pybind11Trampoline.h, so this TU needs the +# pybind11 headers (header-only; no Python embedding required). +target_link_libraries(UnitTest_CharmSerialization pybind11::headers) +endif() if(NOT ${CIBUILDWHEEL}) package_add_test(UnitTest_CCM_HNL ${PROJECT_SOURCE_DIR}/projects/injection/private/test/CCM_HNL_TEST.cxx) target_link_libraries(UnitTest_CCM_HNL pybind11::embed) diff --git a/projects/injection/private/Weighter.cxx b/projects/injection/private/Weighter.cxx index 4c9c389a9..6e7bfb228 100644 --- a/projects/injection/private/Weighter.cxx +++ b/projects/injection/private/Weighter.cxx @@ -49,6 +49,10 @@ using detector::DetectorDirection; //--------------- void Weighter::Initialize() { + // Idempotent: clear any weighters from a prior Initialize() so this can be + // called again after LoadWeighter() or after injectors are overwritten. + primary_process_weighters.clear(); + secondary_process_weighter_maps.clear(); int i = 0; primary_process_weighters.reserve(injectors.size()); secondary_process_weighter_maps.reserve(injectors.size()); @@ -194,11 +198,17 @@ void Weighter::SaveWeighter(std::string const & filename) const { } void Weighter::LoadWeighter(std::string const & filename) { - std::cout << "Weighter loading not yet supported... sorry!\n"; - exit(0); std::ifstream is(filename+".siren_weighter", std::ios::binary); ::cereal::BinaryInputArchive archive(is); - //this->load(archive,0); + // Read members in the same order Weighter::save writes them; the polymorphic + // Injector / PhysicalProcess (and the cross sections and decays they hold) + // are reconstructed via their registered cereal load hooks. Then rebuild the + // per-process weighters. + archive(::cereal::make_nvp("Injectors", injectors)); + archive(::cereal::make_nvp("DetectorModel", detector_model)); + archive(::cereal::make_nvp("PrimaryPhysicalProcess", primary_physical_process)); + archive(::cereal::make_nvp("SecondaryPhysicalProcesses", secondary_physical_processes)); + Initialize(); } Weighter::Weighter(std::vector> injectors, std::shared_ptr detector_model, std::shared_ptr primary_physical_process, std::vector> secondary_physical_processes) diff --git a/projects/injection/private/test/CharmSerialization_TEST.cxx b/projects/injection/private/test/CharmSerialization_TEST.cxx new file mode 100644 index 000000000..6bd55d6b5 --- /dev/null +++ b/projects/injection/private/test/CharmSerialization_TEST.cxx @@ -0,0 +1,131 @@ +// Serialization round-trip tests for the charm-DIS decay classes and the Weighter. +// 1. CharmMesonDecay / CharmMesonDecay3Body must fully consume their body on +// load (both default-constructible, so cereal uses member load(), not +// load_and_construct). A trailing sentinel detects a load that skips the body. +// 2. A Weighter holding a charm decay process must survive SaveWeighter + +// filename-ctor reconstruction (LoadWeighter) and re-serialize byte-identically. + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include "SIREN/interactions/Decay.h" +#include "SIREN/interactions/CharmMesonDecay.h" +#include "SIREN/interactions/CharmMesonDecay3Body.h" +#include "SIREN/interactions/InteractionCollection.h" +#include "SIREN/injection/Process.h" +#include "SIREN/injection/Injector.h" +#include "SIREN/injection/Weighter.h" +#include "SIREN/detector/DetectorModel.h" +#include "SIREN/dataclasses/Particle.h" + +using namespace siren::interactions; +using namespace siren::injection; +using siren::dataclasses::ParticleType; +using siren::detector::DetectorModel; + +namespace { +// Round-trip a polymorphic Decay through a binary archive followed by a sentinel +// int (which reads back correctly only if the body was fully consumed on load). +std::shared_ptr roundtrip_decay_with_sentinel(std::shared_ptr orig, + bool & sentinel_ok) { + const int kSentinel = 0x5A5A5A; + std::stringstream ss; + { + cereal::BinaryOutputArchive oarchive(ss); + oarchive(orig); + int s = kSentinel; + oarchive(s); + } + std::shared_ptr loaded; + int s = 0; + { + cereal::BinaryInputArchive iarchive(ss); + iarchive(loaded); + iarchive(s); + } + sentinel_ok = (s == kSentinel); + return loaded; +} + +std::string read_file(std::string const & path) { + std::ifstream is(path, std::ios::binary); + std::stringstream buf; + buf << is.rdbuf(); + return buf.str(); +} +} // namespace + +TEST(CharmSerialization, CharmMesonDecayPolymorphicRoundTrip) { + std::shared_ptr orig = std::make_shared(ParticleType::D0); + bool sentinel_ok = false; + std::shared_ptr loaded = roundtrip_decay_with_sentinel(orig, sentinel_ok); + ASSERT_NE(loaded, nullptr); + EXPECT_NE(std::dynamic_pointer_cast(loaded), nullptr); + EXPECT_TRUE(orig->equal(*loaded)); + EXPECT_TRUE(loaded->equal(*orig)); + EXPECT_EQ(orig->GetPossibleSignatures().size(), + loaded->GetPossibleSignatures().size()); + EXPECT_TRUE(sentinel_ok) << "decay body was not fully consumed on load"; +} + +TEST(CharmSerialization, CharmMesonDecay3BodyPolymorphicRoundTrip) { + std::shared_ptr orig = std::make_shared(ParticleType::D0); + bool sentinel_ok = false; + std::shared_ptr loaded = roundtrip_decay_with_sentinel(orig, sentinel_ok); + ASSERT_NE(loaded, nullptr); + EXPECT_NE(std::dynamic_pointer_cast(loaded), nullptr); + EXPECT_TRUE(orig->equal(*loaded)); + EXPECT_TRUE(loaded->equal(*orig)); + EXPECT_EQ(orig->GetPossibleSignatures().size(), + loaded->GetPossibleSignatures().size()); + EXPECT_TRUE(sentinel_ok) << "decay body was not fully consumed on load"; +} + +// Weighter SaveWeighter/LoadWeighter with a charm decay process. +TEST(CharmSerialization, WeighterWithCharmDecaySaveLoad) { + auto detector_model = std::make_shared(); + std::shared_ptr decay = std::make_shared(ParticleType::D0); + auto ic = std::make_shared( + ParticleType::D0, std::vector>{decay}); + auto phys = std::make_shared(ParticleType::D0, ic); + + // Empty injectors keep the Weighter construction data-free (Initialize()'s + // per-injector loop is then a no-op); the charm decay still rides through + // the serialized PhysicalProcess graph. + std::vector> no_injectors; + std::vector> no_secondaries; + Weighter w(no_injectors, detector_model, phys, no_secondaries); + + std::string base = "/tmp/siren_charm_weighter_test"; + std::string base2 = base + "_rt"; + std::remove((base + ".siren_weighter").c_str()); + std::remove((base2 + ".siren_weighter").c_str()); + + ASSERT_NO_THROW(w.SaveWeighter(base)); // writes .siren_weighter + + // Filename ctor calls LoadWeighter to reconstruct from the file. + std::unique_ptr w2; + ASSERT_NO_THROW(w2.reset(new Weighter(no_injectors, base))); + ASSERT_NE(w2, nullptr); + + // Re-serialize the loaded weighter; a faithful round-trip is byte-identical. + ASSERT_NO_THROW(w2->SaveWeighter(base2)); + std::string bytes1 = read_file(base + ".siren_weighter"); + std::string bytes2 = read_file(base2 + ".siren_weighter"); + EXPECT_FALSE(bytes1.empty()); + EXPECT_EQ(bytes1, bytes2); + + std::remove((base + ".siren_weighter").c_str()); + std::remove((base2 + ".siren_weighter").c_str()); +} diff --git a/projects/interactions/CMakeLists.txt b/projects/interactions/CMakeLists.txt index c58ff068c..45c480015 100644 --- a/projects/interactions/CMakeLists.txt +++ b/projects/interactions/CMakeLists.txt @@ -19,14 +19,25 @@ LIST (APPEND interactions_SOURCES ${PROJECT_SOURCE_DIR}/projects/interactions/private/DummyCrossSection.cxx ${PROJECT_SOURCE_DIR}/projects/interactions/private/pyDarkNewsCrossSection.cxx ${PROJECT_SOURCE_DIR}/projects/interactions/private/pyDarkNewsDecay.cxx + ${PROJECT_SOURCE_DIR}/projects/interactions/private/CharmMesonDecay.cxx + ${PROJECT_SOURCE_DIR}/projects/interactions/private/CharmMesonDecay3Body.cxx + ${PROJECT_SOURCE_DIR}/projects/interactions/private/DMesonELoss.cxx + ${PROJECT_SOURCE_DIR}/projects/interactions/private/QuarkDISFromSpline.cxx + ) if(TARGET MARLEY) LIST (APPEND interactions_SOURCES ${PROJECT_SOURCE_DIR}/projects/interactions/private/MarleyCrossSection.cxx ) endif() +if(TARGET PYTHIA8) + LIST (APPEND interactions_SOURCES + ${PROJECT_SOURCE_DIR}/projects/interactions/private/PythiaDISCrossSection.cxx + ) +endif() add_library(SIREN_interactions OBJECT ${interactions_SOURCES}) set_property(TARGET SIREN_interactions PROPERTY POSITION_INDEPENDENT_CODE ON) + target_include_directories(SIREN_interactions PUBLIC $ $ @@ -51,6 +62,10 @@ target_link_libraries(SIREN_interactions ) apply_siren_compile_options(SIREN_interactions) +if(TARGET PYTHIA8) + target_link_libraries(SIREN_interactions PUBLIC PYTHIA8) + target_compile_definitions(SIREN_interactions PUBLIC SIREN_HAS_PYTHIA8) +endif() install(DIRECTORY "${PROJECT_SOURCE_DIR}/projects/interactions/public/" DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} @@ -67,6 +82,10 @@ package_add_test(UnitTest_HNLDecay ${PROJECT_SOURCE_DIR}/projects/interactions/p pybind11_add_module(interactions ${PROJECT_SOURCE_DIR}/projects/interactions/private/pybindings/interactions.cxx) target_link_libraries(interactions PRIVATE SIREN photospline rk_static crundec_static pybind11::embed GSL::gsl GSL::gslcblas) +if(TARGET PYTHIA8) + target_link_libraries(interactions PRIVATE PYTHIA8) + target_compile_definitions(interactions PRIVATE SIREN_HAS_PYTHIA8) +endif() if(TARGET MARLEY) target_compile_definitions(interactions PRIVATE SIREN_HAS_MARLEY) endif() @@ -78,3 +97,32 @@ set_target_properties(interactions PROPERTIES BUILD_WITH_INSTALL_RPATH FALSE LINK_FLAGS "-Wl,-rpath,\\\$ORIGIN/../siren.libs/") endif() +package_add_test(UnitTest_CharmMesonDecay ${PROJECT_SOURCE_DIR}/projects/interactions/private/test/CharmMesonDecay_TEST.cxx) + +# Charm-DIS interaction-depth closure invariant (no Pythia8 needed: uses a mock +# that exercises the real base-class CrossSection::TotalCrossSectionAllFinalStates). +package_add_test(UnitTest_CharmDISClosure ${PROJECT_SOURCE_DIR}/projects/interactions/private/test/CharmDISClosure_TEST.cxx) + +# Charm-meson energy-loss and 3-body charm-meson decay (no Pythia8/spline needed; +# both classes link only against SIREN core). +package_add_test(UnitTest_DMesonELoss ${PROJECT_SOURCE_DIR}/projects/interactions/private/test/DMesonELoss_TEST.cxx) +package_add_test(UnitTest_CharmMesonDecay3Body ${PROJECT_SOURCE_DIR}/projects/interactions/private/test/CharmMesonDecay3Body_TEST.cxx) + +# QuarkDISFromSpline density-variable contract tripwire + fragmentation pdf +# normalization (no spline file needed: the no-arg ctor builds only the +# fragmentation pdf/cdf). +package_add_test(UnitTest_QuarkDISDensityContract ${PROJECT_SOURCE_DIR}/projects/interactions/private/test/QuarkDISDensityContract_TEST.cxx) + +# Serialization round-trips for the interaction classes that use a static +# load_and_construct (HNLDecay, ElectroweakDecay, HNLDipoleDecay, +# ElasticScattering, HNLDipoleFromTable). +package_add_test(UnitTest_InteractionSerialization ${PROJECT_SOURCE_DIR}/projects/interactions/private/test/InteractionSerialization_TEST.cxx) + +# Real PythiaDISCrossSection charm-DIS closure/normalization regression test. +# Only built with Pythia8 support; needs charm-DIS spline files via env vars +# (SIREN_PYTHIA_TEST_DSDXDY, SIREN_PYTHIA_TEST_SIGMA), otherwise it SKIPs. +if(TARGET PYTHIA8) + package_add_test(UnitTest_PythiaDISCharmClosure ${PROJECT_SOURCE_DIR}/projects/interactions/private/test/PythiaDISCharmClosure_TEST.cxx) + target_compile_definitions(UnitTest_PythiaDISCharmClosure PRIVATE SIREN_HAS_PYTHIA8) + target_link_libraries(UnitTest_PythiaDISCharmClosure PYTHIA8) +endif() diff --git a/projects/interactions/private/CharmMesonDecay.cxx b/projects/interactions/private/CharmMesonDecay.cxx new file mode 100644 index 000000000..525e01780 --- /dev/null +++ b/projects/interactions/private/CharmMesonDecay.cxx @@ -0,0 +1,555 @@ +#include "SIREN/interactions/CharmMesonDecay.h" + +#include +#include + +#include +#include + +#include "SIREN/dataclasses/InteractionRecord.h" // for Interac... +#include "SIREN/dataclasses/InteractionSignature.h" // for Interac... +#include "SIREN/dataclasses/Particle.h" // for Particle +#include "SIREN/math/Vector3D.h" // for Vector3D +#include "SIREN/utilities/Constants.h" // for GeV, pi +#include "SIREN/utilities/Random.h" // for SIREN_random + +#include "SIREN/utilities/Integration.h" // for rombergInt... +#include "SIREN/utilities/Interpolator.h" + +#include "SIREN/interactions/Decay.h" +#include "SIREN/interactions/CharmDecayKinematics.h" + +namespace siren { +namespace interactions { + +using charm_decay::particleMass; +using charm_decay::KStarMass; + +CharmMesonDecay::CharmMesonDecay() {} + +CharmMesonDecay::CharmMesonDecay(siren::dataclasses::Particle::ParticleType primary) {} + +bool CharmMesonDecay::equal(Decay const & other) const { + const CharmMesonDecay* x = dynamic_cast(&other); + + if(!x) + return false; + else + return primary_types == x->primary_types; +} + +double CharmMesonDecay::TotalDecayWidth(dataclasses::InteractionRecord const & record) const { + return TotalDecayWidth(record.signature.primary_type); +} + +// in this implementation, should we take total decay width to be only the channels we considered? +double CharmMesonDecay::TotalDecayWidth(siren::dataclasses::Particle::ParticleType primary) const { + double total_width = 0; + std::vector possible_signatures = GetPossibleSignaturesFromParent(primary); + for (auto sig : possible_signatures) { + // make a fake record and full from total decay width for final state + siren::dataclasses::InteractionRecord fake_record; + fake_record.signature = sig; + double this_width = TotalDecayWidthForFinalState(fake_record); + total_width += this_width; + } + return total_width; +} + +// current problem: in implementation we see kaons and pions both as hadrons, but they should have different branching ratios and form factors +double CharmMesonDecay::TotalDecayWidthForFinalState(dataclasses::InteractionRecord const & record) const { + // Sentinel-init: all valid branching ratios and lifetimes are strictly + // positive, so a negative value unambiguously flags an unmatched mode. + double branching_ratio = -1.0; + double tau = -1.0; // total lifetime for all visible and invisible modes + // read in the signature and types + siren::dataclasses::Particle::ParticleType primary = record.signature.primary_type; + std::vector secondaries_vector = record.signature.secondary_types; + std::set secondaries = std::set(secondaries_vector.begin(), secondaries_vector.end()); + + // define the decay modes + std::set k0_eplus_nue = {siren::dataclasses::Particle::ParticleType::K0Bar, + siren::dataclasses::Particle::ParticleType::EPlus, + siren::dataclasses::Particle::ParticleType::NuE}; + std::set kminus_eplus_nue = {siren::dataclasses::Particle::ParticleType::KMinus, + siren::dataclasses::Particle::ParticleType::EPlus, + siren::dataclasses::Particle::ParticleType::NuE}; + std::set k0_muplus_numu = {siren::dataclasses::Particle::ParticleType::K0Bar, + siren::dataclasses::Particle::ParticleType::MuPlus, + siren::dataclasses::Particle::ParticleType::NuMu}; + std::set kminus_muplus_numu = {siren::dataclasses::Particle::ParticleType::KMinus, + siren::dataclasses::Particle::ParticleType::MuPlus, + siren::dataclasses::Particle::ParticleType::NuMu}; + std::set hadrons_muplus_numu = {siren::dataclasses::Particle::ParticleType::Hadrons, + siren::dataclasses::Particle::ParticleType::MuPlus, + siren::dataclasses::Particle::ParticleType::NuMu}; + std::set hadrons_eplus_nue = {siren::dataclasses::Particle::ParticleType::Hadrons, + siren::dataclasses::Particle::ParticleType::EPlus, + siren::dataclasses::Particle::ParticleType::NuE}; + std::set hadrons = {siren::dataclasses::Particle::ParticleType::Hadrons}; + // Anti-flavor (cbar) decay-mode sets, sign-conjugated from the c modes above. + std::set k0_eminus_nuebar = {siren::dataclasses::Particle::ParticleType::K0, + siren::dataclasses::Particle::ParticleType::EMinus, + siren::dataclasses::Particle::ParticleType::NuEBar}; + std::set kplus_eminus_nuebar = {siren::dataclasses::Particle::ParticleType::KPlus, + siren::dataclasses::Particle::ParticleType::EMinus, + siren::dataclasses::Particle::ParticleType::NuEBar}; + std::set k0_muminus_numubar = {siren::dataclasses::Particle::ParticleType::K0, + siren::dataclasses::Particle::ParticleType::MuMinus, + siren::dataclasses::Particle::ParticleType::NuMuBar}; + std::set kplus_muminus_numubar = {siren::dataclasses::Particle::ParticleType::KPlus, + siren::dataclasses::Particle::ParticleType::MuMinus, + siren::dataclasses::Particle::ParticleType::NuMuBar}; + std::set hadrons_muminus_numubar = {siren::dataclasses::Particle::ParticleType::Hadrons, + siren::dataclasses::Particle::ParticleType::MuMinus, + siren::dataclasses::Particle::ParticleType::NuMuBar}; + std::set hadrons_eminus_nuebar = {siren::dataclasses::Particle::ParticleType::Hadrons, + siren::dataclasses::Particle::ParticleType::EMinus, + siren::dataclasses::Particle::ParticleType::NuEBar}; + if (primary == siren::dataclasses::Particle::ParticleType::DPlus) { + tau = 1040 * (1e-15); + // Exclusive K + K* semileptonic BR (PDG): K0bar l nu = 8.74%, + // K*0bar l nu = 5.33%, summed to 14.07% because the K l nu signature + // is sampled with kinematic K/K* mixing (see SampleFinalState fracK). + // e and mu modes share the same value (lepton-mass effects sub-percent). + if (secondaries == k0_eplus_nue) {branching_ratio = .1407;} + else if (secondaries == k0_muplus_numu) {branching_ratio = .1407;} + else if (secondaries == hadrons) {branching_ratio = (1 - 2 * .1407);} + } else if (primary == siren::dataclasses::Particle::ParticleType::DMinus) { + // CP-mirror of D+ (same lifetime, same physical PDG BRs). + tau = 1040 * (1e-15); + if (secondaries == k0_eminus_nuebar) {branching_ratio = .1407;} + else if (secondaries == k0_muminus_numubar) {branching_ratio = .1407;} + else if (secondaries == hadrons) {branching_ratio = (1 - 2 * .1407);} + } else if (primary == siren::dataclasses::Particle::ParticleType::D0) { + tau = 410.1 * (1e-15); + // Exclusive K + K* semileptonic BR (PDG): K- l nu = 3.41%, + // K*- l nu = 2.17%, summed to 5.58% (kinematic K/K* mixing, see fracK). + if (secondaries == kminus_eplus_nue) {branching_ratio = .0558;} + else if (secondaries == kminus_muplus_numu) {branching_ratio = .0558;} + else if (secondaries == hadrons) {branching_ratio = (1 - 2 * .0558);} + } else if (primary == siren::dataclasses::Particle::ParticleType::D0Bar) { + // CP-mirror of D0 (same lifetime, same physical PDG BRs). + tau = 410.1 * (1e-15); + if (secondaries == kplus_eminus_nuebar) {branching_ratio = .0558;} + else if (secondaries == kplus_muminus_numubar) {branching_ratio = .0558;} + else if (secondaries == hadrons) {branching_ratio = (1 - 2 * .0558);} + } else if (primary == siren::dataclasses::Particle::ParticleType::DsPlus) { + tau = 504 * (1e-15); // Ds+ lifetime: 504 fs (PDG) + // Physical PDG BRs for Ds+ semileptonic decay. Inclusive + // Ds -> e X = 0.0654, Ds -> mu X = 0.0654 (no tau feed-down). + // Daughter "Hadrons" stands in for eta / eta' / phi (sampled in + // SampleFinalState with fractions 0.46 / 0.16 / 0.38). + if (secondaries == hadrons_eplus_nue) {branching_ratio = .0654;} + else if (secondaries == hadrons_muplus_numu) {branching_ratio = .0654;} + else if (secondaries == hadrons) {branching_ratio = (1 - 2 * .0654);} + } else if (primary == siren::dataclasses::Particle::ParticleType::DsMinus) { + // CP-mirror of Ds+ (eta/eta'/phi are self-conjugate, only lepton/nu flip). + tau = 504 * (1e-15); + if (secondaries == hadrons_eminus_nuebar) {branching_ratio = .0654;} + else if (secondaries == hadrons_muminus_numubar) {branching_ratio = .0654;} + else if (secondaries == hadrons) {branching_ratio = (1 - 2 * .0654);} + } + else { + // Signatures are produced by GetPossibleSignaturesFromParent, so an + // unsupported primary here means the two lists are out of sync. Fail + // loudly rather than return an indeterminate width that would silently + // corrupt TotalDecayWidth / FinalStateProbability. + throw std::runtime_error("CharmMesonDecay::TotalDecayWidthForFinalState: unsupported primary particle type"); + } + // Guard the matched-primary / unmatched-secondaries case (sentinel still set). + if (tau <= 0.0 || branching_ratio < 0.0) { + throw std::runtime_error("CharmMesonDecay::TotalDecayWidthForFinalState: no implemented decay mode matches this signature"); + } + return branching_ratio * siren::utilities::Constants::hbar / tau * siren::utilities::Constants::GeV; +} + + +std::vector CharmMesonDecay::GetPossibleSignatures() const { + std::vector signatures; + for(auto primary : primary_types) { + std::vector new_signatures = GetPossibleSignaturesFromParent(primary); + signatures.insert(signatures.end(),new_signatures.begin(),new_signatures.end()); + } + return signatures; +} + +std::vector CharmMesonDecay::GetPossibleSignaturesFromParent(siren::dataclasses::Particle::ParticleType primary) const { + std::vector signatures; + // initialize semileptonic signatures + dataclasses::InteractionSignature semilep_signature; + semilep_signature.primary_type = primary; + semilep_signature.target_type = siren::dataclasses::Particle::ParticleType::Decay; + semilep_signature.secondary_types.resize(3); + // initialize other signatures + dataclasses::InteractionSignature hadron_signature; + hadron_signature.primary_type = primary; + hadron_signature.target_type = siren::dataclasses::Particle::ParticleType::Decay; + hadron_signature.secondary_types.resize(1); + + if (primary==siren::dataclasses::Particle::ParticleType::DPlus) { + // semi-leptonic modes with muon and electron + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::K0Bar; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::EPlus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuE; + signatures.push_back(semilep_signature); + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::K0Bar; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::MuPlus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuMu; + signatures.push_back(semilep_signature); + // all other modes implemented as one big bang + hadron_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + signatures.push_back(hadron_signature); + } else if (primary==siren::dataclasses::Particle::ParticleType::DMinus) { + // CP-mirror of D+: K0 (sbar d -> contains sbar), e-/mu-, nubar_e/nubar_mu. + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::K0; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::EMinus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuEBar; + signatures.push_back(semilep_signature); + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::K0; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::MuMinus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuMuBar; + signatures.push_back(semilep_signature); + hadron_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + signatures.push_back(hadron_signature); + } else if (primary==siren::dataclasses::Particle::ParticleType::D0) { + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::KMinus; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::EPlus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuE; + + signatures.push_back(semilep_signature); + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::KMinus; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::MuPlus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuMu; + signatures.push_back(semilep_signature); + hadron_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + signatures.push_back(hadron_signature); + } else if (primary==siren::dataclasses::Particle::ParticleType::D0Bar) { + // CP-mirror of D0: K+ (u sbar), e-/mu-, nubar_e/nubar_mu. + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::KPlus; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::EMinus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuEBar; + signatures.push_back(semilep_signature); + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::KPlus; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::MuMinus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuMuBar; + signatures.push_back(semilep_signature); + hadron_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + signatures.push_back(hadron_signature); + } else if (primary==siren::dataclasses::Particle::ParticleType::DsPlus) { + // Ds: muonic + electronic semileptonic channels plus all-hadronic catch-all. + // The Hadrons daughter stands in for eta / eta' / phi (sampled inline + // in SampleFinalState; SIREN ParticleType has no entries for these). + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::EPlus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuE; + signatures.push_back(semilep_signature); + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::MuPlus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuMu; + signatures.push_back(semilep_signature); + hadron_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + signatures.push_back(hadron_signature); + } else if (primary==siren::dataclasses::Particle::ParticleType::DsMinus) { + // CP-mirror of Ds+: eta/eta'/phi are self-conjugate, only lepton/nu flip. + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::EMinus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuEBar; + signatures.push_back(semilep_signature); + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::MuMinus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuMuBar; + signatures.push_back(semilep_signature); + hadron_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + signatures.push_back(hadron_signature); + } + else { + std::cout << "this D meson decay has not been implemented yet" << std::endl; + } + return signatures; +} + +double CharmMesonDecay::DifferentialDecayWidth(dataclasses::InteractionRecord const & record) const { + return TotalDecayWidthForFinalState(record); +} + +// this is temporary implementation +void CharmMesonDecay::SampleFinalStateHadronic(dataclasses::CrossSectionDistributionRecord & record, std::shared_ptr random) const { + std::vector & secondaries = record.GetSecondaryParticleRecords(); + siren::dataclasses::SecondaryParticleRecord & hadrons = secondaries[0]; + rk::P4 p4D_lab(geom3::Vector3(record.primary_momentum[1], record.primary_momentum[2], record.primary_momentum[3]), record.primary_mass); + hadrons.SetFourMomentum({p4D_lab.e(), p4D_lab.px(), p4D_lab.py(), p4D_lab.pz()}); + hadrons.SetMass(p4D_lab.m()); + hadrons.SetHelicity(record.primary_helicity); +} + +void CharmMesonDecay::SampleFinalState(dataclasses::CrossSectionDistributionRecord & record, std::shared_ptr random) const { + // first handle hadronic decay separately. Use size==1 so we don't + // mis-route the Ds semileptonic mode (Hadrons + mu + nu, size 3). + dataclasses::InteractionSignature signature = record.signature; + if (signature.secondary_types.size() == 1 && + signature.secondary_types[0] == siren::dataclasses::Particle::ParticleType::Hadrons) { + SampleFinalStateHadronic(record, random); + return; + } + + // 3-body phase space (Pythia ParticleDecays::threeBody): + // D (m0) -> hadron (m1) + lepton (m2) + neutrino (m3). Sample + // m23 = sqrt(q^2) flat with accept-reject on the phase-space weight. + // D+/D0: V-A matrix-element correction with K vs K* mixing. + // Ds: pure phase space, daughter sampled inline as eta/eta'/phi + // (fractions 0.46/0.16/0.38 from BRs 2.3/0.8/1.9 %). + + siren::dataclasses::Particle::ParticleType primary = record.signature.primary_type; + bool is_Ds = (primary == siren::dataclasses::Particle::ParticleType::DsPlus || + primary == siren::dataclasses::Particle::ParticleType::DsMinus); + + double mD = particleMass(primary); // m0 + double ml = particleMass(record.signature.secondary_types[1]); // m2 + double mnu = 0.0; // m3 + + double mK; // m1: hadron daughter mass (chosen per-decay) + if (is_Ds) { + // Ds -> eta mu nu (BR 2.3%), Ds -> eta' mu nu (0.8%), Ds -> phi mu nu (1.9%) + // Cumulative: eta=0.46, eta+eta'=0.62, all=1.0 + double mEta = siren::utilities::Constants::EtaMass; // 0.547862 GeV + double mEtaPrime = siren::utilities::Constants::EtaPrimeMass; // 0.95778 GeV + double mPhi = siren::utilities::Constants::PhiMass; // 1.019461 GeV + double r = random->Uniform(0, 1); + if (r < 2.3 / 5.0) { + mK = mEta; + } else if (r < 3.1 / 5.0) { + mK = mEtaPrime; + } else { + mK = mPhi; + } + } else { + // D+/D0: K vs K*(892) with V-A weighting + double mK_base = particleMass(record.signature.secondary_types[0]); + // K*(892) mass; read from Constants so the FinalStateProbability + // normalizer (KStarMass()) and the sampler use the identical value + // (required for weighting closure). + double mKstar = KStarMass(); + double fracK; + if (primary == siren::dataclasses::Particle::ParticleType::DPlus || + primary == siren::dataclasses::Particle::ParticleType::DMinus) { + // D+ -> K0bar mu+ nu: BR=8.74%, D+ -> K*0bar mu+ nu: BR=5.33% + // (D- BRs identical by CP.) + fracK = 8.74 / (8.74 + 5.33); // ~0.621 + } else { + // D0 -> K- mu+ nu: BR=3.41%, D0 -> K*- mu+ nu: BR=2.17% + // (D0bar BRs identical by CP.) + fracK = 3.41 / (3.41 + 2.17); // ~0.611 + } + mK = (random->Uniform(0, 1) < fracK) ? mK_base : mKstar; + } + + // D meson 4-momentum in lab frame + rk::P4 p4D_lab(geom3::Vector3(record.primary_momentum[1], + record.primary_momentum[2], + record.primary_momentum[3]), + record.primary_mass); + geom3::UnitVector3 x_dir = geom3::UnitVector3::xAxis(); + + // Kinematic limits for m23 (lepton-neutrino invariant mass) + double m23Min = ml + mnu; // minimum: lepton + neutrino at rest + double m23Max = mD - mK; // maximum: kaon at rest + double mDiff = m23Max - m23Min; + + // Maximum phase space weight: p1Max * p23Max + // p1Max = kaon momentum when m23 = m23Min + double p1Max = 0.5 * sqrt((mD - mK - m23Min) * (mD + mK + m23Min) + * (mD + mK - m23Min) * (mD - mK + m23Min)) / mD; + // p23Max = lepton momentum in m23 rest frame when m23 = m23Max + double p23Max = 0.5 * sqrt((m23Max - ml - mnu) * (m23Max + ml + mnu) + * (m23Max + ml - mnu) * (m23Max - ml + mnu)) / m23Max; + double wtPSmax = 0.5 * p1Max * p23Max; + + // V-A matrix element upper bound (from Pythia, meMode == 22). + // For Ds we skip V-A weighting and use pure 3-body phase space -- set + // wtMEmax = 1.0 and wtME = 1.0 inside the loop so the rejection test + // (wtME < rand * wtMEmax) is always false and we exit after one iteration. + double wtMEmax = is_Ds + ? 1.0 + : std::min(std::pow(mD, 4) / 16.0, + mD * (mD - mK - ml) * (mD - mK - mnu) * (mD - ml - mnu)); + double wtME; + + rk::P4 p4K_Drest, p4l_Drest, p4nu_Drest; + + do { + wtME = 1.0; + + // --- Step 1: Sample m23 flat, accept-reject on phase space weight --- + double m23, p1Abs, p23Abs, wtPS; + do { + m23 = m23Min + random->Uniform(0, 1) * mDiff; + + // p1Abs = kaon momentum in D rest frame for this m23 + p1Abs = 0.5 * sqrt((mD - mK - m23) * (mD + mK + m23) + * (mD + mK - m23) * (mD - mK + m23)) / mD; + // p23Abs = lepton momentum in m23 rest frame + p23Abs = 0.5 * sqrt((m23 - ml - mnu) * (m23 + ml + mnu) + * (m23 + ml - mnu) * (m23 - ml + mnu)) / m23; + wtPS = p1Abs * p23Abs; + } while (wtPS < random->Uniform(0, 1) * wtPSmax); + + // --- Step 2: Set up m23 -> lepton + neutrino isotropically --- + double cosTheta23 = random->Uniform(-1, 1); + double sinTheta23 = std::sin(std::acos(cosTheta23)); + double phi23 = random->Uniform(0, 2 * M_PI); + + // Lepton and neutrino in m23 rest frame + geom3::Vector3 dir23(sinTheta23 * std::cos(phi23), + sinTheta23 * std::sin(phi23), + cosTheta23); + rk::P4 p4l_m23rest(p23Abs * dir23, ml); + rk::P4 p4nu_m23rest(-p23Abs * dir23, mnu); + + // --- Step 3: Set up D -> K + (m23 system) isotropically --- + double cosTheta1 = random->Uniform(-1, 1); + double sinTheta1 = std::sin(std::acos(cosTheta1)); + double phi1 = random->Uniform(0, 2 * M_PI); + + geom3::Vector3 dir1(sinTheta1 * std::cos(phi1), + sinTheta1 * std::sin(phi1), + cosTheta1); + // Kaon in D rest frame + p4K_Drest = rk::P4(p1Abs * dir1, mK); + // m23 system in D rest frame (opposite to kaon) + rk::P4 p4m23_Drest(-p1Abs * dir1, m23); + + // --- Step 4: Boost lepton and neutrino from m23 rest frame to D rest frame --- + rk::Boost boost_m23_to_Drest = p4m23_Drest.labBoost(); + p4l_Drest = p4l_m23rest.boost(boost_m23_to_Drest); + p4nu_Drest = p4nu_m23rest.boost(boost_m23_to_Drest); + + // --- Step 5: V-A matrix element weight (skipped for Ds -- pure phase space) --- + // wtME = m_D * E_lepton * (p_neutrino . p_kaon) in D rest frame + // This is Lorentz invariant up to the m_D * E_l factor which equals p_D . p_l + if (!is_Ds) { + wtME = mD * p4l_Drest.e() * p4nu_Drest.dot(p4K_Drest); + } + // For Ds: wtME stays at 1.0 (set at the top of the do-loop). Combined with + // wtMEmax = 1.0, the test (wtME < rand[0,1) * wtMEmax) is always false, + // so we exit after one iteration with no V-A reweighting. + + } while (wtME < random->Uniform(0, 1) * wtMEmax); + + // --- Boost all particles from D rest frame to lab frame --- + rk::Boost boost_D_to_lab = p4D_lab.labBoost(); + rk::P4 p4K_lab = p4K_Drest.boost(boost_D_to_lab); + rk::P4 p4l_lab = p4l_Drest.boost(boost_D_to_lab); + rk::P4 p4nu_lab = p4nu_Drest.boost(boost_D_to_lab); + + // --- Set secondary particle records --- + std::vector & secondaries = record.GetSecondaryParticleRecords(); + siren::dataclasses::SecondaryParticleRecord & kpi = secondaries[0]; + siren::dataclasses::SecondaryParticleRecord & lepton = secondaries[1]; + siren::dataclasses::SecondaryParticleRecord & neutrino = secondaries[2]; + + kpi.SetFourMomentum({p4K_lab.e(), p4K_lab.px(), p4K_lab.py(), p4K_lab.pz()}); + kpi.SetMass(p4K_lab.m()); + kpi.SetHelicity(record.primary_helicity); + + lepton.SetFourMomentum({p4l_lab.e(), p4l_lab.px(), p4l_lab.py(), p4l_lab.pz()}); + lepton.SetMass(p4l_lab.m()); + lepton.SetHelicity(record.primary_helicity); + + neutrino.SetFourMomentum({p4nu_lab.e(), p4nu_lab.px(), p4nu_lab.py(), p4nu_lab.pz()}); + neutrino.SetMass(p4nu_lab.m()); + neutrino.SetHelicity(record.primary_helicity); + +} + +// FinalStateProbability is the normalized q^2 density SampleFinalState produces +// (closure rationale in CharmDecayKinematics.h). It builds the same hadron-mass +// mixture the sampler used, evaluates charm_decay::SampledQ2Density for the +// recorded component, and normalizes via charm_decay::SampledQ2Normalization. +double CharmMesonDecay::FinalStateProbability(dataclasses::InteractionRecord const & record) const { + dataclasses::InteractionSignature signature = record.signature; + // Guard: finalize() does not copy the signature, so a finalized record can + // arrive with an empty signature/secondaries -> indexing below would be UB. + if (signature.secondary_types.empty()) { + throw std::runtime_error("CharmMesonDecay::FinalStateProbability: record has an empty signature. Set record.signature (and secondary masses/momenta) before calling; finalize() does not copy the signature."); + } + // Fully hadronic catch-all: a single Hadrons daughter carries the full + // primary 4-momentum, so the final state is deterministic and its density is 1. + if (signature.secondary_types.size() == 1 && + signature.secondary_types[0] == siren::dataclasses::Particle::ParticleType::Hadrons) { + return 1.0; + } + + siren::dataclasses::Particle::ParticleType primary = signature.primary_type; + bool is_Ds = (primary == siren::dataclasses::Particle::ParticleType::DsPlus || + primary == siren::dataclasses::Particle::ParticleType::DsMinus); + bool apply_va = !is_Ds; // Ds is pure phase space (no V-A matrix element) + + if (record.secondary_masses.size() < 2 || record.secondary_momenta.empty()) { + throw std::runtime_error("CharmMesonDecay::FinalStateProbability: record secondaries are not populated (need >=2 masses and >=1 momentum)."); + } + // Reconstruct q^2 = (p_D - p_hadron)^2 exactly as SampleFinalState defines it. + double mD = record.primary_mass; + double ml = record.secondary_masses[1]; + double mK = record.secondary_masses[0]; // hadron mass actually sampled + rk::P4 pD(geom3::Vector3(record.primary_momentum[1], record.primary_momentum[2], record.primary_momentum[3]), mD); + rk::P4 pK(geom3::Vector3(record.secondary_momenta[0][1], record.secondary_momenta[0][2], record.secondary_momenta[0][3]), mK); + double q2 = (pD - pK).dot(pD - pK); + + // Build the hadron-mass mixture (masses + population fractions) that the + // sampler uses, identify which component the recorded mK belongs to, and + // normalize each component separately so the mixture weights are the true + // population fractions. + std::vector masses; + std::vector fractions; + if (is_Ds) { + // Ds -> (eta / eta' / phi) l nu, fractions 0.46 / 0.16 / 0.38 (matches + // the cumulative thresholds 2.3/5.0, 3.1/5.0 used in SampleFinalState). + masses = {siren::utilities::Constants::EtaMass, + siren::utilities::Constants::EtaPrimeMass, + siren::utilities::Constants::PhiMass}; + fractions = {2.3 / 5.0, 0.8 / 5.0, 1.9 / 5.0}; + } else { + // D+/D0 -> K l nu with kinematic K/K*(892) mixing. fracK matches the + // value used in SampleFinalState (PDG K vs K* semileptonic BRs). + double mK_base = particleMass(signature.secondary_types[0]); + double mKstar = KStarMass(); + double fracK; + if (primary == siren::dataclasses::Particle::ParticleType::DPlus || + primary == siren::dataclasses::Particle::ParticleType::DMinus) { + fracK = 8.74 / (8.74 + 5.33); + } else { + fracK = 3.41 / (3.41 + 2.17); + } + masses = {mK_base, mKstar}; + fractions = {fracK, 1.0 - fracK}; + } + + // Identify the component matching the recorded hadron mass. + int comp = -1; + double best = 1e30; + for (size_t i = 0; i < masses.size(); ++i) { + double d = std::abs(mK - masses[i]); + if (d < best) { best = d; comp = (int)i; } + } + if (comp < 0) return 0.0; + + double g = charm_decay::SampledQ2Density(mD, masses[comp], ml, q2, apply_va); + if (g <= 0.0) return 0.0; + double norm = charm_decay::SampledQ2Normalization(mD, masses[comp], ml, apply_va, norm_cache); + if (norm <= 0.0) return 0.0; + + // FinalStateProbability = mixture_fraction * (component density / component norm), + // a true normalized q^2 pdf summing over the hadron-mass mixture. + return fractions[comp] * g / norm; +} + +std::vector CharmMesonDecay::DensityVariables() const { + return std::vector{"Q2"}; +} + + + +} // namespace interactions +} // namespace siren + diff --git a/projects/interactions/private/CharmMesonDecay3Body.cxx b/projects/interactions/private/CharmMesonDecay3Body.cxx new file mode 100644 index 000000000..d5a756f8a --- /dev/null +++ b/projects/interactions/private/CharmMesonDecay3Body.cxx @@ -0,0 +1,386 @@ +#include "SIREN/interactions/CharmMesonDecay3Body.h" + +#include +#include + +#include +#include + +#include "SIREN/dataclasses/InteractionRecord.h" // for Interac... +#include "SIREN/dataclasses/InteractionSignature.h" // for Interac... +#include "SIREN/dataclasses/Particle.h" // for Particle +#include "SIREN/math/Vector3D.h" // for Vector3D +#include "SIREN/utilities/Constants.h" // for GeV, pi +#include "SIREN/utilities/Random.h" // for SIREN_random + +#include "SIREN/utilities/Integration.h" // for rombergInt... +#include "SIREN/utilities/Interpolator.h" + +#include "SIREN/interactions/Decay.h" +#include "SIREN/interactions/CharmDecayKinematics.h" + +namespace siren { +namespace interactions { + +using charm_decay::particleMass; +using charm_decay::KStarMass; + +CharmMesonDecay3Body::CharmMesonDecay3Body() {} + +CharmMesonDecay3Body::CharmMesonDecay3Body(siren::dataclasses::Particle::ParticleType primary) { + if (primary != siren::dataclasses::Particle::ParticleType::DPlus && + primary != siren::dataclasses::Particle::ParticleType::D0) { + throw std::runtime_error("CharmMesonDecay3Body: only D0 and D+ are implemented. Use CharmMesonDecay, which covers D0/D+/Ds and their anti-flavors."); + } +} + +bool CharmMesonDecay3Body::equal(Decay const & other) const { + const CharmMesonDecay3Body* x = dynamic_cast(&other); + + if(!x) + return false; + else + return primary_types == x->primary_types; +} + +double CharmMesonDecay3Body::TotalDecayWidth(dataclasses::InteractionRecord const & record) const { + return TotalDecayWidth(record.signature.primary_type); +} + +// in this implementation, should we take total decay width to be only the channels we considered? +double CharmMesonDecay3Body::TotalDecayWidth(siren::dataclasses::Particle::ParticleType primary) const { + double total_width = 0; + std::vector possible_signatures = GetPossibleSignaturesFromParent(primary); + for (auto sig : possible_signatures) { + // make a fake record and full from total decay width for final state + siren::dataclasses::InteractionRecord fake_record; + fake_record.signature = sig; + double this_width = TotalDecayWidthForFinalState(fake_record); + total_width += this_width; + } + return total_width; +} + +// current problem: in implementation we see kaons and pions both as hadrons, but they should have different branching ratios and form factors +double CharmMesonDecay3Body::TotalDecayWidthForFinalState(dataclasses::InteractionRecord const & record) const { + // Sentinel-init: all valid branching ratios and lifetimes are strictly + // positive, so a negative value unambiguously flags an unmatched mode. + double branching_ratio = -1.0; + double tau = -1.0; // total lifetime for all visible and invisible modes + // read in the signature and types + siren::dataclasses::Particle::ParticleType primary = record.signature.primary_type; + std::vector secondaries_vector = record.signature.secondary_types; + std::set secondaries = std::set(secondaries_vector.begin(), secondaries_vector.end()); + + // define the decay modes + std::set k0_eplus_nue = {siren::dataclasses::Particle::ParticleType::K0Bar, + siren::dataclasses::Particle::ParticleType::EPlus, + siren::dataclasses::Particle::ParticleType::NuE}; + std::set kminus_eplus_nue = {siren::dataclasses::Particle::ParticleType::KMinus, + siren::dataclasses::Particle::ParticleType::EPlus, + siren::dataclasses::Particle::ParticleType::NuE}; + std::set k0_muplus_numu = {siren::dataclasses::Particle::ParticleType::K0Bar, + siren::dataclasses::Particle::ParticleType::MuPlus, + siren::dataclasses::Particle::ParticleType::NuMu}; + std::set kminus_muplus_numu = {siren::dataclasses::Particle::ParticleType::KMinus, + siren::dataclasses::Particle::ParticleType::MuPlus, + siren::dataclasses::Particle::ParticleType::NuMu}; + std::set hadrons = {siren::dataclasses::Particle::ParticleType::Hadrons}; + if (primary == siren::dataclasses::Particle::ParticleType::DPlus) { + tau = 1040 * (1e-15); + // Exclusive K + K* semileptonic BR (PDG): K0bar l nu = 8.74%, + // K*0bar l nu = 5.33%, summed to 14.07% (kinematic K/K* mixing, see fracK). + // e and mu modes share the same value (lepton-mass effects sub-percent). + if (secondaries == k0_eplus_nue) {branching_ratio = .1407;} + else if (secondaries == k0_muplus_numu) {branching_ratio = .1407;} + else if (secondaries == hadrons) {branching_ratio = (1 - 2 * .1407);} // everything else + } else if (primary == siren::dataclasses::Particle::ParticleType::D0) { + tau = 410.1 * (1e-15); + // Exclusive K + K* semileptonic BR (PDG): K- l nu = 3.41%, + // K*- l nu = 2.17%, summed to 5.58% (kinematic K/K* mixing, see fracK). + if (secondaries == kminus_eplus_nue) {branching_ratio = .0558;} + else if (secondaries == kminus_muplus_numu) {branching_ratio = .0558;} + else if (secondaries == hadrons) {branching_ratio = (1 - 2 * .0558);} // everything else + } + else { + // Signatures come from GetPossibleSignaturesFromParent, so an unsupported + // primary means the lists are out of sync. Fail loudly rather than + // return an indeterminate width. + throw std::runtime_error("CharmMesonDecay3Body::TotalDecayWidthForFinalState: unsupported primary particle type"); + } + // Guard the matched-primary / unmatched-secondaries case (sentinel still set). + if (tau <= 0.0 || branching_ratio < 0.0) { + throw std::runtime_error("CharmMesonDecay3Body::TotalDecayWidthForFinalState: no implemented decay mode matches this signature"); + } + return branching_ratio * siren::utilities::Constants::hbar / tau * siren::utilities::Constants::GeV; +} + + +std::vector CharmMesonDecay3Body::GetPossibleSignatures() const { + std::vector signatures; + for(auto primary : primary_types) { + std::vector new_signatures = GetPossibleSignaturesFromParent(primary); + signatures.insert(signatures.end(),new_signatures.begin(),new_signatures.end()); + } + return signatures; +} + +std::vector CharmMesonDecay3Body::GetPossibleSignaturesFromParent(siren::dataclasses::Particle::ParticleType primary) const { + std::vector signatures; + // initialize semileptonic signatures + dataclasses::InteractionSignature semilep_signature; + semilep_signature.primary_type = primary; + semilep_signature.target_type = siren::dataclasses::Particle::ParticleType::Decay; + semilep_signature.secondary_types.resize(3); + // initialize other signatures + dataclasses::InteractionSignature hadron_signature; + hadron_signature.primary_type = primary; + hadron_signature.target_type = siren::dataclasses::Particle::ParticleType::Decay; + hadron_signature.secondary_types.resize(1); + + if (primary==siren::dataclasses::Particle::ParticleType::DPlus) { + // semi-leptonic modes with muon and electron + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::K0Bar; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::EPlus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuE; + signatures.push_back(semilep_signature); + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::K0Bar; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::MuPlus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuMu; + signatures.push_back(semilep_signature); + // all other modes implemented as one big bang + hadron_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + signatures.push_back(hadron_signature); + } else if (primary==siren::dataclasses::Particle::ParticleType::D0) { + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::KMinus; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::EPlus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuE; + signatures.push_back(semilep_signature); + semilep_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::KMinus; + semilep_signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::MuPlus; + semilep_signature.secondary_types[2] = siren::dataclasses::Particle::ParticleType::NuMu; + signatures.push_back(semilep_signature); + hadron_signature.secondary_types[0] = siren::dataclasses::Particle::ParticleType::Hadrons; + signatures.push_back(hadron_signature); + } + else { + throw std::runtime_error("CharmMesonDecay3Body::GetPossibleSignaturesFromParent: only D0 and D+ are implemented. Use CharmMesonDecay for D0/D+/Ds and anti-flavors."); + } + return signatures; +} + +double CharmMesonDecay3Body::DifferentialDecayWidth(dataclasses::InteractionRecord const & record) const { + return TotalDecayWidthForFinalState(record); +} + +// this is temporary implementation +void CharmMesonDecay3Body::SampleFinalStateHadronic(dataclasses::CrossSectionDistributionRecord & record, std::shared_ptr random) const { + std::vector & secondaries = record.GetSecondaryParticleRecords(); + siren::dataclasses::SecondaryParticleRecord & hadrons = secondaries[0]; + rk::P4 p4D_lab(geom3::Vector3(record.primary_momentum[1], record.primary_momentum[2], record.primary_momentum[3]), record.primary_mass); + hadrons.SetFourMomentum({p4D_lab.e(), p4D_lab.px(), p4D_lab.py(), p4D_lab.pz()}); + hadrons.SetMass(p4D_lab.m()); + hadrons.SetHelicity(record.primary_helicity); +} + +void CharmMesonDecay3Body::SampleFinalState(dataclasses::CrossSectionDistributionRecord & record, std::shared_ptr random) const { + // Hadronic decay branch -- identical to the 2-body class. + dataclasses::InteractionSignature signature = record.signature; + if (signature.secondary_types[0] == siren::dataclasses::Particle::ParticleType::Hadrons) { + SampleFinalStateHadronic(record, random); + return; + } + + // 3-body phase space (Pythia ParticleDecays::threeBody): + // D (m0) -> K (m1) + lepton (m2) + neutrino (m3). Sample + // m23 = sqrt(q^2) flat with accept-reject on the phase-space weight, then + // apply the V-A matrix-element correction. + // + // K/K*(892) mixing is KINEMATIC only: a fraction (1 - fracK) of events draw + // the K*(892) mass for the hadron, broadening the lepton spectrum. The + // secondary's ParticleType stays the signature K species (K0bar / K-) -- we + // neither advertise separate K* signatures nor re-type the secondary, since + // only the mass matters for weighting the lepton/neutrino kinematics. + + double mD = particleMass(record.signature.primary_type); + double mK_base = particleMass(record.signature.secondary_types[0]); // K mass from signature + double ml = particleMass(record.signature.secondary_types[1]); // lepton mass + double mnu = 0.0; // (massless) neutrino + + // K/K* mixing fractions from PDG semileptonic branching ratios + // K*(892) mass read from Constants so the sampler and the + // FinalStateProbability normalizer (KStarMass()) use the identical value + // (required for weighting closure). + double mKstar = KStarMass(); + double fracK; + if (record.signature.primary_type == siren::dataclasses::Particle::ParticleType::DPlus) { + // D+ -> Kbar0 l nu: BR_K = 8.74%, BR_K*bar = 5.33% + fracK = 8.74 / (8.74 + 5.33); + } else { + // D0 -> K- l nu: BR_K = 3.41%, BR_K*- = 2.17% + fracK = 3.41 / (3.41 + 2.17); + } + double mK = (random->Uniform(0, 1) < fracK) ? mK_base : mKstar; + + // D meson 4-momentum in lab frame (needed for final lab-frame boost) + rk::P4 p4D_lab(geom3::Vector3(record.primary_momentum[1], + record.primary_momentum[2], + record.primary_momentum[3]), + record.primary_mass); + + // Phase-space limits for m23 = sqrt(q^2) = (lepton+nu) invariant mass + double m23Min = ml + mnu; + double m23Max = mD - mK; + double mDiff = m23Max - m23Min; + + // Kinematic envelope used as the accept-reject ceiling + double p1Max = 0.5 * std::sqrt((mD - mK - m23Min) * (mD + mK + m23Min) + * (mD + mK - m23Min) * (mD - mK + m23Min)) / mD; + double p23Max = 0.5 * std::sqrt((m23Max - ml - mnu) * (m23Max + ml + mnu) + * (m23Max + ml - mnu) * (m23Max - ml + mnu)) / m23Max; + double wtPSmax = 0.5 * p1Max * p23Max; + + // V-A matrix element upper bound (Pythia meMode == 22) + double wtMEmax = std::min(std::pow(mD, 4) / 16.0, + mD * (mD - mK - ml) * (mD - mK - mnu) * (mD - ml - mnu)); + + rk::P4 p4K_Drest, p4l_Drest, p4nu_Drest; + double wtME; + + // --- Outer accept-reject on the V-A matrix element --- + do { + wtME = 1.0; + + // --- Inner accept-reject on 3-body phase space --- + double m23, p1Abs, p23Abs, wtPS; + do { + m23 = m23Min + random->Uniform(0, 1) * mDiff; + p1Abs = 0.5 * std::sqrt((mD - mK - m23) * (mD + mK + m23) + * (mD + mK - m23) * (mD - mK + m23)) / mD; + p23Abs = 0.5 * std::sqrt((m23 - ml - mnu) * (m23 + ml + mnu) + * (m23 + ml - mnu) * (m23 - ml + mnu)) / m23; + wtPS = p1Abs * p23Abs; + } while (wtPS < random->Uniform(0, 1) * wtPSmax); + + // Set up m23 -> lepton + neutrino, isotropic in m23 rest frame + double cosTheta23 = random->Uniform(-1, 1); + double sinTheta23 = std::sin(std::acos(cosTheta23)); + double phi23 = random->Uniform(0, 2 * M_PI); + geom3::Vector3 dir23(sinTheta23 * std::cos(phi23), + sinTheta23 * std::sin(phi23), + cosTheta23); + rk::P4 p4l_m23rest(p23Abs * dir23, ml); + rk::P4 p4nu_m23rest(-p23Abs * dir23, mnu); + + // Set up D -> K + (m23 system), isotropic in D rest frame + double cosTheta1 = random->Uniform(-1, 1); + double sinTheta1 = std::sin(std::acos(cosTheta1)); + double phi1 = random->Uniform(0, 2 * M_PI); + geom3::Vector3 dir1(sinTheta1 * std::cos(phi1), + sinTheta1 * std::sin(phi1), + cosTheta1); + p4K_Drest = rk::P4(p1Abs * dir1, mK); + rk::P4 p4m23_Drest(-p1Abs * dir1, m23); + + // Boost lepton/neutrino from m23 rest frame to D rest frame + rk::Boost boost_m23_to_Drest = p4m23_Drest.labBoost(); + p4l_Drest = p4l_m23rest.boost(boost_m23_to_Drest); + p4nu_Drest = p4nu_m23rest.boost(boost_m23_to_Drest); + + // V-A matrix element weight: + // wtME = m_D * E_lepton * (p_neutrino . p_kaon) in D rest frame + wtME = mD * p4l_Drest.e() * p4nu_Drest.dot(p4K_Drest); + } while (wtME < random->Uniform(0, 1) * wtMEmax); + + // Boost kaon / lepton / neutrino from D rest frame to lab frame + rk::Boost boost_D_to_lab = p4D_lab.labBoost(); + rk::P4 p4K_lab = p4K_Drest.boost(boost_D_to_lab); + rk::P4 p4l_lab = p4l_Drest.boost(boost_D_to_lab); + rk::P4 p4nu_lab = p4nu_Drest.boost(boost_D_to_lab); + + // Write the secondaries + std::vector & secondaries = record.GetSecondaryParticleRecords(); + siren::dataclasses::SecondaryParticleRecord & kpi = secondaries[0]; + siren::dataclasses::SecondaryParticleRecord & lepton = secondaries[1]; + siren::dataclasses::SecondaryParticleRecord & neutrino = secondaries[2]; + + kpi.SetFourMomentum({p4K_lab.e(), p4K_lab.px(), p4K_lab.py(), p4K_lab.pz()}); + kpi.SetMass(p4K_lab.m()); + kpi.SetHelicity(record.primary_helicity); + + lepton.SetFourMomentum({p4l_lab.e(), p4l_lab.px(), p4l_lab.py(), p4l_lab.pz()}); + lepton.SetMass(p4l_lab.m()); + lepton.SetHelicity(record.primary_helicity); + + neutrino.SetFourMomentum({p4nu_lab.e(), p4nu_lab.px(), p4nu_lab.py(), p4nu_lab.pz()}); + neutrino.SetMass(p4nu_lab.m()); + neutrino.SetHelicity(record.primary_helicity); +} + +// FinalStateProbability is the normalized q^2 density SampleFinalState produces +// (closure rationale in CharmDecayKinematics.h). Builds the same K/K*(892) +// mixture the sampler used, evaluates charm_decay::SampledQ2Density for the +// recorded component, and normalizes via charm_decay::SampledQ2Normalization. +double CharmMesonDecay3Body::FinalStateProbability(dataclasses::InteractionRecord const & record) const { + dataclasses::InteractionSignature signature = record.signature; + // Guard: finalize() does not copy the signature, so a finalized record can + // arrive with an empty signature/secondaries -> indexing below would be UB. + if (signature.secondary_types.empty()) { + throw std::runtime_error("CharmMesonDecay3Body::FinalStateProbability: record has an empty signature. Set record.signature (and secondary masses/momenta) before calling; finalize() does not copy the signature."); + } + // Fully hadronic catch-all: deterministic final state, density 1. + if (signature.secondary_types[0] == siren::dataclasses::Particle::ParticleType::Hadrons) { + return 1.0; + } + + siren::dataclasses::Particle::ParticleType primary = signature.primary_type; + bool apply_va = true; // 3-body class always applies the V-A matrix element + + if (record.secondary_masses.size() < 2 || record.secondary_momenta.empty()) { + throw std::runtime_error("CharmMesonDecay3Body::FinalStateProbability: record secondaries are not populated (need >=2 masses and >=1 momentum)."); + } + double mD = record.primary_mass; + double ml = record.secondary_masses[1]; + double mK = record.secondary_masses[0]; // hadron mass actually sampled + rk::P4 pD(geom3::Vector3(record.primary_momentum[1], record.primary_momentum[2], record.primary_momentum[3]), mD); + rk::P4 pK(geom3::Vector3(record.secondary_momenta[0][1], record.secondary_momenta[0][2], record.secondary_momenta[0][3]), mK); + double q2 = (pD - pK).dot(pD - pK); + + // K/K*(892) kinematic mixture matching SampleFinalState's fracK. + double mK_base = particleMass(signature.secondary_types[0]); + double mKstar = KStarMass(); + double fracK; + if (primary == siren::dataclasses::Particle::ParticleType::DPlus) { + fracK = 8.74 / (8.74 + 5.33); + } else { + fracK = 3.41 / (3.41 + 2.17); + } + std::vector masses = {mK_base, mKstar}; + std::vector fractions = {fracK, 1.0 - fracK}; + + int comp = -1; + double best = 1e30; + for (size_t i = 0; i < masses.size(); ++i) { + double d = std::abs(mK - masses[i]); + if (d < best) { best = d; comp = (int)i; } + } + if (comp < 0) return 0.0; + + double g = charm_decay::SampledQ2Density(mD, masses[comp], ml, q2, apply_va); + if (g <= 0.0) return 0.0; + double norm = charm_decay::SampledQ2Normalization(mD, masses[comp], ml, apply_va, norm_cache); + if (norm <= 0.0) return 0.0; + + // Normalized q^2 pdf summing over the K/K* mixture. + return fractions[comp] * g / norm; +} + +std::vector CharmMesonDecay3Body::DensityVariables() const { + return std::vector{"Q2"}; +} + + + +} // namespace interactions +} // namespace siren + diff --git a/projects/interactions/private/DMesonELoss.cxx b/projects/interactions/private/DMesonELoss.cxx new file mode 100644 index 000000000..debca2e3f --- /dev/null +++ b/projects/interactions/private/DMesonELoss.cxx @@ -0,0 +1,247 @@ +#include "SIREN/interactions/DMesonELoss.h" + +#include // for map, opera... +#include // for set, opera... +#include // for array +#include // for pow, log10 +#include // for tie, opera... +#include // for allocator +#include // for basic_string +#include // for vector +#include // for assert +#include // for size_t + +#include // for P4, Boost +#include // for Vector3 + +#include "SIREN/interactions/CrossSection.h" // for CrossSection +#include "SIREN/dataclasses/InteractionRecord.h" // for Interactio... +#include "SIREN/dataclasses/Particle.h" // for Particle +#include "SIREN/utilities/Random.h" // for SIREN_random +#include "SIREN/utilities/Constants.h" // for electronMass +#include "SIREN/utilities/Integration.h" // for rombergInt... +#include "SIREN/utilities/Errors.h" // for InjectionFailure + + +namespace siren { +namespace interactions { + +DMesonELoss::DMesonELoss() { +} + + +bool DMesonELoss::equal(CrossSection const & other) const { + const DMesonELoss* x = dynamic_cast(&other); + + if(!x) + return false; + else + return + std::tie( + primary_types_, + target_types_) + == + std::tie( + x->primary_types_, + x->target_types_); +} + + + +std::vector DMesonELoss::GetPossiblePrimaries() const { + return std::vector(primary_types_.begin(), primary_types_.end()); +} + +// getting target should be the same for all the primary types +std::vector DMesonELoss::GetPossibleTargetsFromPrimary(siren::dataclasses::Particle::ParticleType primary_type) const { + return std::vector(target_types_.begin(), target_types_.end()); +} + +std::vector DMesonELoss::GetPossibleTargets() const { + return std::vector(target_types_.begin(), target_types_.end()); +} + +std::vector DMesonELoss::GetPossibleSignatures() const { + std::vector signatures; + for(auto primary : primary_types_) { + // hardcode the target type here, this should be fine + std::vector new_signatures = GetPossibleSignaturesFromParents(primary, siren::dataclasses::Particle::ParticleType::PPlus); + signatures.insert(signatures.end(),new_signatures.begin(),new_signatures.end()); + } + return signatures; +} + +std::vector DMesonELoss::GetPossibleSignaturesFromParents(siren::dataclasses::Particle::ParticleType primary_type, siren::dataclasses::Particle::ParticleType target_type) const { + std::vector signatures; + dataclasses::InteractionSignature signature; + signature.primary_type = primary_type; + signature.target_type = target_type; + + // first we deal with semileptonic decays where there are 3 final state particles + signature.secondary_types.resize(2); + signature.secondary_types[0] = primary_type; // same particle comes out + signature.secondary_types[1] = siren::dataclasses::Particle::ParticleType::Hadrons; // there also is a hadronic vertex + signatures.push_back(signature); + return signatures; +} + +double DMesonELoss::TotalCrossSection(dataclasses::InteractionRecord const & interaction) const { + siren::dataclasses::Particle::ParticleType primary_type = interaction.signature.primary_type; + rk::P4 p1(geom3::Vector3(interaction.primary_momentum[1], interaction.primary_momentum[2], interaction.primary_momentum[3]), interaction.primary_mass); + double primary_energy = interaction.primary_momentum[0]; + + + return TotalCrossSection(primary_type, primary_energy); +} + +double DMesonELoss::TotalCrossSection(siren::dataclasses::Particle::ParticleType primary_type, double primary_energy) const { + if(not primary_types_.count(primary_type)) { + throw std::runtime_error("Supplied primary not supported by cross section!"); + } + double log_energy = log10(primary_energy); + double mb_to_cm2 = 1e-27; + + // current implementation uses only > 1PeV data + double xsec = exp(1.891 + 0.205 * log_energy) - 2.157 + 1.264 * log_energy; + + return xsec * mb_to_cm2; +} + +double DMesonELoss::DifferentialCrossSection(dataclasses::InteractionRecord const & interaction) const { + double primary_energy = interaction.primary_momentum[0]; + double Dmass = interaction.primary_mass; + + double final_energy = interaction.secondary_momenta[0][0]; + double z = 1 - final_energy / primary_energy; + + // Density support must match the sampler's realized support or + // FinalStateProbability is mis-normalized (closure break, worst at low E). + // Apply the same energy-dependent cut z <= 1 - Dmass/E the sampler enforces and + // normalize the Gaussian over [z_min_, z_hi]; z_hi collapses below z_min_ for + // sub-threshold primaries (E <= Dmass), where there is no valid final state. + double z_kin = 1.0 - Dmass / primary_energy; // kinematic upper limit + double z_hi = (z_max_ < z_kin) ? z_max_ : z_kin; + if(z_hi <= z_min_ || z < z_min_ || z > z_hi) { + return 0.0; + } + + // normalize the gaussian over the realized support [z_min_, z_hi] + double total_xsec = TotalCrossSection(interaction.signature.primary_type, primary_energy); + double z0 = 0.56; + double sigma = 0.2; + std::function integrand = [&] (double zz) -> double { + return exp(-(pow(zz - z0, 2))/(2 * pow(sigma, 2))); + }; + double unnormalized = siren::utilities::rombergIntegrate(integrand, z_min_, z_hi); + double normalization = total_xsec / unnormalized; + + double diff_xsec = normalization * exp(-(pow(z - z0, 2))/(2 * pow(sigma, 2))); + + return diff_xsec; +} + + +double DMesonELoss::InteractionThreshold(dataclasses::InteractionRecord const & interaction) const { + // Consider implementing DIS thershold at some point + return 0; +} + +void DMesonELoss::SampleFinalState(dataclasses::CrossSectionDistributionRecord& interaction, std::shared_ptr random) const { + + rk::P4 p1(geom3::Vector3(interaction.primary_momentum[1], interaction.primary_momentum[2], interaction.primary_momentum[3]), interaction.primary_mass); + double primary_energy; + double Dmass = interaction.primary_mass; + rk::P4 p1_lab; + p1_lab = p1; + primary_energy = p1_lab.e(); + + // Below the D meson mass there is no kinematically valid final state (the + // D meson would need energy >= Dmass), and the reject loop would spin for an + // astronomically large number of trials. Short-circuit with a recoverable + // InjectionFailure so the Injector can retry the attempt instead of hanging. + if (primary_energy <= Dmass) { + throw siren::utilities::InjectionFailure( + "DMesonELoss::SampleFinalState: primary energy below D meson mass; no valid final state"); + } + + // sample an inelasticity from gaussian using Box-Muller Transform + double sigma = 0.2; + double z0 = 0.56; // for mesons only, for baryons it's 0.59, but not implemented yet + double u1, u2; + double final_energy; + bool accept; + + // Cap the rejection loop so a degenerate acceptance rate (primary energy just + // above Dmass) terminates deterministically instead of hanging. Mirrors the + // QuarkDISFromSpline reject-loop trial cap. + const int max_trials = 1000; + int trials = 0; + + do { + if (++trials > max_trials) { + throw siren::utilities::InjectionFailure( + "DMesonELoss::SampleFinalState: failed to sample inelasticity within trial cap"); + } + do + { + u1 = random->Uniform(0, 1); + } + while (u1 == 0); + u2 = random->Uniform(0, 1); + double z = sigma * sqrt(-2.0 * log(u1)) * cos(2.0 * M_PI * u2) + z0; + // now modify the energy of the charm hadron and the corresponding momentum + final_energy = primary_energy * (1-z); + // Reject z outside [z_min_, z_max_] so the realized density matches the + // truncated Gaussian the density normalizes over (closure). z < z_min_ would + // let the D GAIN energy; final_energy^2 >= Dmass^2 is a defensive guard. + accept = (z >= z_min_) && (z <= z_max_) && + (pow(final_energy, 2) - pow(Dmass, 2) >= 0); + } while (!accept); + // Guaranteed by z >= z_min_ > 0: the D meson loses energy. + assert(final_energy <= primary_energy); + double p3f = sqrt(pow(final_energy, 2) - pow(Dmass, 2)); + double p3i = std::sqrt(std::pow(p1.px(), 2) + std::pow(p1.py(), 2) + std::pow(p1.pz(), 2)); + double p_ratio = p3f / p3i; + rk::P4 pf(p_ratio * geom3::Vector3(p1.px(), p1.py(), p1.pz()), Dmass); + double E_H = primary_energy - pf.e(); // rest of the energy go into the nucleus + double p_H = E_H; // assume collinearity, energy conservation and not momentum conservation ie massless (for now) + double H_ratio = p_H / p3i; + rk::P4 p4_H(H_ratio * geom3::Vector3(p1.px(), p1.py(), p1.pz()), 0); + + std::vector & secondaries = interaction.GetSecondaryParticleRecords(); + siren::dataclasses::SecondaryParticleRecord & dmeson = secondaries[0]; + siren::dataclasses::SecondaryParticleRecord & hadron = secondaries[1]; + + dmeson.SetFourMomentum({pf.e(), pf.px(), pf.py(), pf.pz()}); + dmeson.SetMass(pf.m()); + dmeson.SetHelicity(interaction.primary_helicity); + + hadron.SetFourMomentum({p4_H.e(), p4_H.px(), p4_H.py(), p4_H.pz()}); + hadron.SetMass(p4_H.m()); + hadron.SetHelicity(interaction.primary_helicity); +} + +double DMesonELoss::FinalStateProbability(dataclasses::InteractionRecord const & interaction) const { + double dxs = DifferentialCrossSection(interaction); + double txs = TotalCrossSection(interaction); + if(dxs == 0) { + return 0.0; + } else { + return dxs / txs; + } +} + +// SampleFinalState samples a single inelasticity DOF z (D set collinear, no +// azimuth), fully captured by the density (truncated, normalized Gaussian in z). +// Sampled DOF == density DOF, so this is closure-safe in the unbiased config. +// Biasing the D kinematics with a separate phase-space channel is unsupported. +std::vector DMesonELoss::DensityVariables() const { + return std::vector{"Bjorken y"}; +} + +void DMesonELoss::InitializeSignatures() { + return; +} + +} // namespace interactions +} // namespace siren diff --git a/projects/interactions/private/PythiaDISCrossSection.cxx b/projects/interactions/private/PythiaDISCrossSection.cxx new file mode 100644 index 000000000..da43860e9 --- /dev/null +++ b/projects/interactions/private/PythiaDISCrossSection.cxx @@ -0,0 +1,715 @@ +#include "SIREN/interactions/PythiaDISCrossSection.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include + +#include "SIREN/interactions/CrossSection.h" +#include "SIREN/interactions/CharmCrossSectionHelpers.h" +#include "SIREN/dataclasses/InteractionRecord.h" +#include "SIREN/dataclasses/Particle.h" +#include "SIREN/utilities/Random.h" +#include "SIREN/utilities/Constants.h" +#include "SIREN/utilities/Errors.h" + +namespace siren { +namespace interactions { + +// --- SIRENRndm: bridges SIREN RNG into Pythia --- + +class SIRENRndm : public Pythia8::RndmEngine { +public: + mutable std::shared_ptr rng_; + double flat() override { return rng_->Uniform(0.0, 1.0); } +}; + +namespace { +// Charm-DIS Pythia configuration shared by SampleFinalState (InitializePythia) +// and the spline generator, so generated total/differential splines correspond +// to exactly the events SampleFinalState produces. Route both through here to +// keep them in lockstep. +void ApplyPythiaCharmConfig(Pythia8::Pythia & pythia, int interaction_type, + int beam_id, int target_pdg, double E_nu, + std::string const & pdf_set, double minimum_Q2) { + if (interaction_type == 1) pythia.readString("WeakBosonExchange:ff2ff(t:W) = on"); + else if (interaction_type == 2) pythia.readString("WeakBosonExchange:ff2ff(t:gmZ) = on"); + pythia.readString("Beams:frameType = 2"); + pythia.readString("Beams:idA = " + std::to_string(beam_id)); + pythia.readString("Beams:idB = " + std::to_string(target_pdg)); + pythia.readString("Beams:eA = " + std::to_string(E_nu)); + pythia.readString("Beams:eB = 0."); + if (interaction_type == 1) { + // Charm-only CC: zero non-charm CKM so every event produces charm. + pythia.settings.forceParm("StandardModel:Vud", 0.0); + pythia.settings.forceParm("StandardModel:Vus", 0.0); + pythia.settings.forceParm("StandardModel:Vub", 0.0); + pythia.settings.forceParm("StandardModel:Vcb", 0.0); + } + pythia.readString("PDF:pSet = " + pdf_set); + pythia.readString("PDF:useHard = on"); + pythia.readString("PDF:pHardSet = " + pdf_set); + pythia.readString("HadronLevel:Hadronize = on"); + pythia.readString("HadronLevel:Decay = off"); + pythia.readString("PartonLevel:MPI = off"); + pythia.readString("PhaseSpace:mHatMin = 0.0"); + pythia.readString("PhaseSpace:Q2Min = " + std::to_string(minimum_Q2)); + pythia.readString("Print:quiet = on"); +} +} // anonymous namespace + +// --- Constructors --- + +PythiaDISCrossSection::PythiaDISCrossSection() {} + +PythiaDISCrossSection::~PythiaDISCrossSection() = default; + +PythiaDISCrossSection::PythiaDISCrossSection( + std::string differential_filename, + std::string total_filename, + int interaction_type, + double target_mass, + double minimum_Q2, + std::set primary_types, + std::set target_types, + std::string pythia_data_path, + std::string pdf_set, + std::string units) + : primary_types_(primary_types), + target_types_(target_types), + interaction_type_(interaction_type), + target_mass_(target_mass), + minimum_Q2_(minimum_Q2), + pdf_set_(pdf_set), + pythia_data_path_(pythia_data_path) +{ + LoadFromFile(differential_filename, total_filename); + InitializeSignatures(); + SetUnits(units); +} + +PythiaDISCrossSection::PythiaDISCrossSection( + std::string differential_filename, + std::string total_filename, + int interaction_type, + double target_mass, + double minimum_Q2, + std::vector primary_types, + std::vector target_types, + std::string pythia_data_path, + std::string pdf_set, + std::string units) + : primary_types_(primary_types.begin(), primary_types.end()), + target_types_(target_types.begin(), target_types.end()), + interaction_type_(interaction_type), + target_mass_(target_mass), + minimum_Q2_(minimum_Q2), + pdf_set_(pdf_set), + pythia_data_path_(pythia_data_path) +{ + LoadFromFile(differential_filename, total_filename); + InitializeSignatures(); + SetUnits(units); +} + +// --- File I/O --- + +void PythiaDISCrossSection::SetUnits(std::string units) { + unit = charm_xsec::UnitForString(units); +} + +void PythiaDISCrossSection::LoadFromFile(std::string dd_crossSectionFile, std::string total_crossSectionFile) { + // Total spline is mandatory (drives interaction depth / position / survival). + total_cross_section_ = photospline::splinetable<>(total_crossSectionFile.c_str()); + if(total_cross_section_.get_ndim() != 1) + throw std::runtime_error("Total cross section spline has " + std::to_string(total_cross_section_.get_ndim()) + + " dimensions, should have 1"); + // Differential spline is OPTIONAL: an empty filename means "no differential", + // in which case FinalStateProbability returns a constant (unbiased-only). + if(dd_crossSectionFile.empty()) { + has_differential_ = false; + } else { + differential_cross_section_ = photospline::splinetable<>(dd_crossSectionFile.c_str()); + if(differential_cross_section_.get_ndim() != 3 && differential_cross_section_.get_ndim() != 2) + throw std::runtime_error("cross section spline has " + std::to_string(differential_cross_section_.get_ndim()) + + " dimensions, should have either 3 or 2"); + has_differential_ = true; + } +} + +void PythiaDISCrossSection::LoadFromMemory(std::vector & differential_data, std::vector & total_data) { + total_cross_section_.read_fits_mem(total_data.data(), total_data.size()); + if(!differential_data.empty()) { + differential_cross_section_.read_fits_mem(differential_data.data(), differential_data.size()); + has_differential_ = true; + } else { + has_differential_ = false; + } +} + +void PythiaDISCrossSection::ReadParamsFromSplineTable() { + bool mass_good = differential_cross_section_.read_key("TARGETMASS", target_mass_); + bool int_good = differential_cross_section_.read_key("INTERACTION", interaction_type_); + bool q2_good = differential_cross_section_.read_key("Q2MIN", minimum_Q2_); + if(!int_good) interaction_type_ = 1; + if(!q2_good) minimum_Q2_ = 1; + if(!mass_good) target_mass_ = siren::utilities::Constants::isoscalarMass; +} + +// --- Equality --- + +bool PythiaDISCrossSection::equal(CrossSection const & other) const { + const PythiaDISCrossSection* x = dynamic_cast(&other); + if(!x) return false; + return std::tie(interaction_type_, target_mass_, minimum_Q2_, signatures_, primary_types_, target_types_) + == std::tie(x->interaction_type_, x->target_mass_, x->minimum_Q2_, x->signatures_, x->primary_types_, x->target_types_); +} + +// --- Particle ID helpers --- + +bool PythiaDISCrossSection::IsCharmedHadron(int pdgId) { + int abs_id = std::abs(pdgId); + // Match D0 (421), D+/- (411), Ds (431). Lambda_c (4122) still routed to hadronic remnant. + return (abs_id == 411 || abs_id == 421 || abs_id == 431); +} + +siren::dataclasses::ParticleType PythiaDISCrossSection::PdgToParticleType(int pdgId) { + // Direct cast -- SIREN ParticleType enum values match PDG codes + return static_cast(pdgId); +} + +double PythiaDISCrossSection::GetLeptonMass(siren::dataclasses::ParticleType lepton_type) { + return charm_xsec::GetLeptonMass(lepton_type); +} + +std::map PythiaDISCrossSection::getIndices(siren::dataclasses::InteractionSignature signature) { + // Identify meson by elimination (not via isD(), which only covers D0/D+/-). + // First pass: claim lepton and Hadrons. Second pass: whatever remains is the meson. + int lepton_id = -1, hadron_id = -1, meson_id = -1; + for (size_t i = 0; i < signature.secondary_types.size(); i++) { + if (siren::dataclasses::isLepton(signature.secondary_types[i])) { + lepton_id = i; + } else if (signature.secondary_types[i] == siren::dataclasses::ParticleType::Hadrons) { + hadron_id = i; + } + } + for (size_t i = 0; i < signature.secondary_types.size(); i++) { + if ((int)i == lepton_id || (int)i == hadron_id) continue; + meson_id = i; + break; + } + return {{"lepton", lepton_id}, {"hadron", hadron_id}, {"meson", meson_id}}; +} + +// --- Signatures --- + +void PythiaDISCrossSection::InitializeSignatures() { + // Charm is forced only in CC (non-charm CKM zeroed in ApplyPythiaCharmConfig). + // Z exchange has no such handle, so NC charm can't be forced and sampling would + // exhaust its budget; reject NC at construction (use QuarkDISFromSpline for NC). + if(interaction_type_ != 1) { + if(interaction_type_ == 2) + throw std::runtime_error("PythiaDISCrossSection supports only charged-current charm production (interaction_type=1). Neutral-current charm is not forced by Z exchange in Pythia, so per-event sampling would fail; use QuarkDISFromSpline for NC charm."); + throw std::runtime_error("PythiaDISCrossSection: unsupported interaction_type=" + std::to_string(interaction_type_) + " (expected 1 for charged current)."); + } + signatures_.clear(); + for(auto primary_type : primary_types_) { + dataclasses::InteractionSignature signature; + signature.primary_type = primary_type; + if(not siren::dataclasses::isNeutrino(primary_type)) { + throw std::runtime_error("PythiaDISCrossSection only supports neutrinos as primaries!"); + } + + siren::dataclasses::ParticleType charged_lepton_product = charm_xsec::ChargedLeptonProduct(primary_type); + siren::dataclasses::ParticleType neutral_lepton_product = primary_type; + + if(interaction_type_ == 1) { + signature.secondary_types.push_back(charged_lepton_product); + } else if(interaction_type_ == 2) { + signature.secondary_types.push_back(neutral_lepton_product); + } else { + throw std::runtime_error("InitializeSignatures: Unknown interaction type!"); + } + + // Hadron remnant + signature.secondary_types.push_back(siren::dataclasses::ParticleType::Hadrons); + + // Charmed meson types: nu -> D0/D+/Ds+, nubar -> Dbar0/D-/Ds-. + // SampleFinalState writes Pythia's actual PID into the meson slot, so the + // registered set must carry the correct charge or weighter lookups go out of + // range (NaN weight). TODO: Add Lambda_c (4122) support. + bool is_antineutrino = + (primary_type == siren::dataclasses::ParticleType::NuEBar || + primary_type == siren::dataclasses::ParticleType::NuMuBar || + primary_type == siren::dataclasses::ParticleType::NuTauBar); + std::set D_types_local; + if (is_antineutrino) { + D_types_local = {siren::dataclasses::ParticleType::D0Bar, + siren::dataclasses::ParticleType::DMinus, + siren::dataclasses::ParticleType::DsMinus}; + } else { + D_types_local = {siren::dataclasses::ParticleType::D0, + siren::dataclasses::ParticleType::DPlus, + siren::dataclasses::ParticleType::DsPlus}; + } + + for (auto meson_type : D_types_local) { + dataclasses::InteractionSignature full_signature = signature; + full_signature.secondary_types.push_back(meson_type); + for(auto target_type : target_types_) { + full_signature.target_type = target_type; + signatures_.push_back(full_signature); + std::pair key(primary_type, target_type); + signatures_by_parent_types_[key].push_back(full_signature); + } + } + } +} + +// --- Cross sections (from splines, same as QuarkDISFromSpline) --- + +double PythiaDISCrossSection::TotalCrossSection(dataclasses::InteractionRecord const & interaction) const { + siren::dataclasses::ParticleType primary_type = interaction.signature.primary_type; + double primary_energy = interaction.primary_momentum[0]; + if(primary_energy < InteractionThreshold(interaction)) return 0; + double total_xs = TotalCrossSection(primary_type, primary_energy); + // Partition the inclusive charm cross section across D species by fragmentation + // fraction; without this, summing the three registered D-type signatures would + // triple-count charm production. Mirrors QuarkDISFromSpline::TotalCrossSection. + for(auto const & sec_type : interaction.signature.secondary_types) { + if(siren::dataclasses::isD(sec_type)) { + total_xs *= FragmentationFraction(sec_type); + break; + } + } + return total_xs; +} + +double PythiaDISCrossSection::TotalCrossSection(siren::dataclasses::ParticleType primary_type, double primary_energy) const { + if(not primary_types_.count(primary_type)) { + throw std::runtime_error("Supplied primary not supported by cross section!"); + } + double log_energy = log10(primary_energy); + if(log_energy < total_cross_section_.lower_extent(0) + or log_energy > total_cross_section_.upper_extent(0)) { + throw std::runtime_error("PythiaDISCrossSection: primary energy " + + std::to_string(primary_energy) + " GeV is outside the total cross-section spline range [" + + std::to_string(std::pow(10.0, total_cross_section_.lower_extent(0))) + ", " + + std::to_string(std::pow(10.0, total_cross_section_.upper_extent(0))) + + "] GeV; regenerate the total spline (siren.pythia_charm_splines.generate_total_spline) to cover this energy."); + } + int center; + total_cross_section_.searchcenters(&log_energy, ¢er); + double log_xs = total_cross_section_.ndsplineeval(&log_energy, ¢er, 0); + return unit * std::pow(10.0, log_xs); +} + + +double PythiaDISCrossSection::DifferentialCrossSection(dataclasses::InteractionRecord const & interaction) const { + if(!has_differential_) + throw std::runtime_error("PythiaDISCrossSection::DifferentialCrossSection called but no differential spline was loaded (the unbiased default uses a constant FinalStateProbability)."); + rk::P4 p1(geom3::Vector3(interaction.primary_momentum[1], interaction.primary_momentum[2], interaction.primary_momentum[3]), interaction.primary_mass); + rk::P4 p2(geom3::Vector3(0, 0, 0), interaction.target_mass); + double primary_energy = interaction.primary_momentum[0]; + + std::map secondaries = getIndices(interaction.signature); + int lepton_index_i = secondaries["lepton"]; + // Guard against a malformed/empty signature: getIndices returns -1 when no + // lepton is found, and using that as an unsigned index reads out of bounds. + if(lepton_index_i < 0 || static_cast(lepton_index_i) >= interaction.secondary_momenta.size()) { + return 0.0; + } + unsigned int lepton_index = static_cast(lepton_index_i); + + std::array const & mom3 = interaction.secondary_momenta[lepton_index]; + rk::P4 p3(geom3::Vector3(mom3[1], mom3[2], mom3[3]), interaction.secondary_masses[lepton_index]); + rk::P4 q = p1 - p3; + + double Q2 = -q.dot(q); + double lepton_mass = GetLeptonMass(interaction.signature.secondary_types[lepton_index]); + double y = 1.0 - p2.dot(p3) / p2.dot(p1); + double p2q = p2.dot(q); + double x = (p2q != 0.0) ? Q2 / (2.0 * p2q) : -1.0; // muon-reconstructed Bjorken x + + // Use the reconstructed (x,y) when they fall inside the spline domain; the + // spline's support is the validity region (no separate analytic charm-DIS + // kinematic gate, which assumes a model the Pythia-derived spline does not). + double log_energy = log10(primary_energy); + std::array centers; + bool ok = (x > 0.0 && x < 1.0 && y > 0.0 && y < 1.0 && Q2 >= minimum_Q2_); + if(ok) { + std::array coordinates{{log_energy, log10(x), log10(y)}}; + ok = differential_cross_section_.searchcenters(coordinates.data(), centers.data()); + } + if(!ok) { + // Fall back to the stored (x,y) -- the SAME muon-reconstructed Bjorken + // variables SampleFinalState records. Non-throwing: if absent (e.g. a + // fake record built for a rate query), the differential is undefined -> 0. + auto itx = interaction.interaction_parameters.find("bjorken_x"); + auto ity = interaction.interaction_parameters.find("bjorken_y"); + if(itx == interaction.interaction_parameters.end() || ity == interaction.interaction_parameters.end()) + return 0.0; + x = itx->second; + y = ity->second; + Q2 = 2.0 * primary_energy * p2.e() * x * y; + } + return DifferentialCrossSection(primary_energy, x, y, lepton_mass, Q2); +} + +double PythiaDISCrossSection::DifferentialCrossSection(double energy, double x, double y, double secondary_lepton_mass, double Q2) const { + if(!has_differential_) + throw std::runtime_error("PythiaDISCrossSection::DifferentialCrossSection called but no differential spline was loaded (the unbiased default uses a constant FinalStateProbability)."); + double log_energy = log10(energy); + // Out of spline support -> raise, never silently return 0 (would bias the + // event's weight to zero). Energy extent and the (x,y) grid define validity. + if(log_energy < differential_cross_section_.lower_extent(0) + || log_energy > differential_cross_section_.upper_extent(0)) + throw std::runtime_error("PythiaDISCrossSection: energy " + std::to_string(energy) + + " GeV is outside the differential spline energy range [" + + std::to_string(std::pow(10.0, differential_cross_section_.lower_extent(0))) + ", " + + std::to_string(std::pow(10.0, differential_cross_section_.upper_extent(0))) + + "] GeV; regenerate the differential spline to cover this energy."); + if(x <= 0 || x >= 1 || y <= 0 || y >= 1) + throw std::runtime_error("PythiaDISCrossSection: unphysical Bjorken (x=" + + std::to_string(x) + ", y=" + std::to_string(y) + ") outside (0, 1)."); + if(std::isnan(Q2)) Q2 = 2.0 * energy * target_mass_ * x * y; + if(Q2 < minimum_Q2_) return 0; // below the configured Q^2 cut: a modeling threshold, not a spline-range miss + (void)secondary_lepton_mass; // analytic charm-DIS gate removed (see above) + + std::array coordinates{{log_energy, log10(x), log10(y)}}; + std::array centers; + if(!differential_cross_section_.searchcenters(coordinates.data(), centers.data())) + throw std::runtime_error("PythiaDISCrossSection: Bjorken (x=" + std::to_string(x) + + ", y=" + std::to_string(y) + ") at E=" + std::to_string(energy) + + " GeV is outside the differential spline (x, y) grid; widen the spline's logx/logy range."); + double result = pow(10., differential_cross_section_.ndsplineeval(coordinates.data(), centers.data(), 0)); + return unit * result; +} + +double PythiaDISCrossSection::InteractionThreshold(dataclasses::InteractionRecord const & interaction) const { + return 0; +} + +// --- Fragmentation fractions --- + +double PythiaDISCrossSection::FragmentationFraction(siren::dataclasses::Particle::ParticleType secondary) const { + return charm_xsec::FragmentationFraction(secondary); +} + +double PythiaDISCrossSection::FinalStateProbability(dataclasses::InteractionRecord const & interaction) const { + // Pythia's per-event density is not analytic. With NO differential spline, + // return a constant: in the unbiased config it cancels in the weight ratio and + // only TotalCrossSection matters (biasing the final state is unsupported). + if(!has_differential_) return 1.0; + + // With a Pythia-derived differential spline, report dsigma/sigma. The + // fragmentation fraction in TotalCrossSection cancels per-signature. + double dxs = DifferentialCrossSection(interaction); + double txs = TotalCrossSection(interaction); + if (!std::isfinite(dxs) || !std::isfinite(txs) || dxs <= 0 || txs <= 0) return 0.0; + double result = dxs / txs; + if (!std::isfinite(result)) return 0.0; + return result; +} + +// --- Signature accessors --- + +std::vector PythiaDISCrossSection::GetPossiblePrimaries() const { + return charm_xsec::ToVector(primary_types_); +} + +std::vector PythiaDISCrossSection::GetPossibleTargetsFromPrimary(siren::dataclasses::ParticleType primary_type) const { + return charm_xsec::ToVector(target_types_); +} + +std::vector PythiaDISCrossSection::GetPossibleSignatures() const { + return std::vector(signatures_.begin(), signatures_.end()); +} + +std::vector PythiaDISCrossSection::GetPossibleTargets() const { + return charm_xsec::ToVector(target_types_); +} + +std::vector PythiaDISCrossSection::GetPossibleSignaturesFromParents(siren::dataclasses::ParticleType primary_type, siren::dataclasses::ParticleType target_type) const { + return charm_xsec::SignaturesForParents(signatures_by_parent_types_, primary_type, target_type); +} + +std::vector PythiaDISCrossSection::DensityVariables() const { + return std::vector{"Bjorken x", "Bjorken y"}; +} + +// ====================================================================== +// Pythia initialization and SampleFinalState +// ====================================================================== + +void PythiaDISCrossSection::InitializePythia(double E_nu, int target_pdg) const { + // Ensure LHAPDF can find PDF sets -- derive data path from the LHAPDF library location + // The pdf_set_ is e.g. "LHAPDF6:HERAPDF20_NLO_EIG", and LHAPDF needs LHAPDF_DATA_PATH set + const char* lhapdf_path = std::getenv("LHAPDF_DATA_PATH"); + if (!lhapdf_path) { + throw std::runtime_error("LHAPDF_DATA_PATH is not set; set it to your LHAPDF data directory (the parent of the PDF set folders)."); + } + + // Pre-flight: verify the requested PDF set is actually installed under + // LHAPDF_DATA_PATH. A missing set otherwise surfaces only as Pythia's opaque + // "initialization failed" much later; name the set and where we looked. + { + std::string set_name = pdf_set_; + auto colon = set_name.find(':'); // strip an "LHAPDF6:" prefix + if (colon != std::string::npos) set_name = set_name.substr(colon + 1); + std::string set_dir = std::string(lhapdf_path) + "/" + set_name; + struct stat st; + if (stat(set_dir.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) { + throw std::runtime_error("PythiaDISCrossSection: LHAPDF PDF set '" + set_name + + "' not found under LHAPDF_DATA_PATH=" + std::string(lhapdf_path) + + " (looked for " + set_dir + "). Install it (e.g. 'lhapdf install " + set_name + + "') or construct with a pdf_set that is installed."); + } + } + + pythia_ = std::make_unique(pythia_data_path_, false); + + // Determine beam particle from primary types + int beam_id = 14; // default nu_mu + for (auto pt : primary_types_) { + beam_id = static_cast(pt); + break; // use the first primary type + } + + ApplyPythiaCharmConfig(*pythia_, interaction_type_, beam_id, target_pdg, E_nu, pdf_set_, minimum_Q2_); + + if (!pythia_->init()) { + throw std::runtime_error("PythiaDISCrossSection: Pythia initialization failed"); + } + + // Bridge SIREN RNG into Pythia for reproducibility. + // Must be done after init() -- init uses Pythia's internal RNG for setup. + // The SIREN RNG is connected here and updated per-event in SampleFinalState. + siren_rndm_ = std::make_shared(); + pythia_->rndm.rndmEnginePtr(siren_rndm_); +} + +void PythiaDISCrossSection::GeneratePythiaCharmSamples( + int interaction_type, int primary_pdg, int target_pdg, double target_mass, + std::string pdf_set, std::string pythia_data_path, double minimum_Q2, + std::vector const & energies, int n_events, + std::vector & out_sigma_mb, + std::vector & out_E, std::vector & out_x, std::vector & out_y) +{ + // Run Pythia once per energy (fast: ~ms/event after init) using the SAME + // configuration SampleFinalState uses, recording the muon-reconstructed + // Bjorken (x, y) per charm event and Pythia's generated cross section per + // energy. The python generate_*_spline helpers fit these into FITS splines. + out_sigma_mb.clear(); out_E.clear(); out_x.clear(); out_y.clear(); + if (!std::getenv("LHAPDF_DATA_PATH")) + throw std::runtime_error("GeneratePythiaCharmSamples: LHAPDF_DATA_PATH is not set"); + bool is_cc = (interaction_type == 1); + for (double E : energies) { + Pythia8::Pythia pythia(pythia_data_path, false); + ApplyPythiaCharmConfig(pythia, interaction_type, primary_pdg, target_pdg, E, pdf_set, minimum_Q2); + if (!pythia.init()) + throw std::runtime_error("GeneratePythiaCharmSamples: Pythia init failed"); + for (int i = 0; i < n_events; ++i) { + if (!pythia.next()) continue; + int i_lep = -1; + for (int j = 0; j < pythia.event.size(); ++j) { + if (!pythia.event[j].isFinal()) continue; + int ap = std::abs(pythia.event[j].id()); + bool is_lep = is_cc ? (ap == 11 || ap == 13 || ap == 15) + : (ap == 12 || ap == 14 || ap == 16); + if (is_lep) { i_lep = j; break; } + } + if (i_lep < 0) continue; + Pythia8::Vec4 pl = pythia.event[i_lep].p(); + double y = 1.0 - pl.e() / E; + if (y <= 0.0 || y >= 1.0) continue; + Pythia8::Vec4 pnu(0.0, 0.0, E, E); // (px,py,pz,e), nu along +z + Pythia8::Vec4 q4 = pnu - pl; + double Q2 = -q4.m2Calc(); + if (Q2 <= 0.0) continue; + double x = Q2 / (2.0 * target_mass * E * y); + if (x <= 0.0 || x >= 1.0) continue; + out_E.push_back(E); out_x.push_back(x); out_y.push_back(y); + } + out_sigma_mb.push_back(pythia.info.sigmaGen()); + } +} + +void PythiaDISCrossSection::SampleFinalState(dataclasses::CrossSectionDistributionRecord & record, std::shared_ptr random) const { + // Get indices for lepton, hadron, meson in the secondary list + std::map secondary_indices = getIndices(record.signature); + unsigned int lepton_index = secondary_indices["lepton"]; + unsigned int hadron_index = secondary_indices["hadron"]; + unsigned int meson_index = secondary_indices["meson"]; + + // Get primary neutrino 4-momentum + rk::P4 p1(geom3::Vector3(record.primary_momentum[1], record.primary_momentum[2], record.primary_momentum[3]), record.primary_mass); + double E_nu = p1.e(); + geom3::UnitVector3 nu_dir = p1.momentum().direction(); + + // Sample target nucleon PDG: 10/18 proton (2212), 8/18 neutron (2112) + // to match H2O composition of ice (10 protons, 8 neutrons per molecule). + // Pythia requires a concrete hadron beam; SIREN's "Nucleon" abstraction + // is resolved here, per event, via the SIREN RNG. + int target_pdg = (random->Uniform(0.0, 1.0) < (10.0 / 18.0)) ? 2212 : 2112; + + // Re-initialize Pythia every event so Beams:eA tracks this event's E_nu. + // Pythia 8's variable-energy mode is not supported for WeakBosonExchange + // processes, so setKinematics cannot be used here. Rebuilding the Pythia + // object is expensive (~1 s/event) but is the only way to keep the + // sampled muon kinematics consistent with the event's true beam energy. + InitializePythia(E_nu, target_pdg); + + // Bridge the SIREN RNG for this event + siren_rndm_->rng_ = random; + + // The outgoing primary lepton is fixed by the signature: the charged lepton + // for CC (e/mu/tau, signed), or the same-flavor neutrino for NC. Match Pythia's + // final state against that exact PDG instead of hardcoding the muon, so NC and + // nu_e / nu_tau work, not only nu_mu CC. + int expected_lepton_pdg = static_cast(record.signature.secondary_types[lepton_index]); + + // Generate a Pythia event + int max_attempts = 100; + bool found_charm = false; + for (int attempt = 0; attempt < max_attempts; ++attempt) { + if (!pythia_->next()) { + continue; + } + + // Find the outgoing lepton and charmed hadron in the final state + int i_lep = -1; + int i_charm = -1; + Pythia8::Vec4 p_remnant(0., 0., 0., 0.); + + for (int i = 0; i < pythia_->event.size(); ++i) { + if (!pythia_->event[i].isFinal()) continue; + + int pid = pythia_->event[i].id(); + + if (pid == expected_lepton_pdg && i_lep < 0) { + // Primary outgoing lepton matching the signature (CC charged + // lepton or NC neutrino, with the correct charge). + i_lep = i; + } else if (IsCharmedHadron(pid) && i_charm < 0) { + // First charmed hadron + i_charm = i; + } else { + // Everything else goes into the remnant + p_remnant += pythia_->event[i].p(); + } + } + + if (i_lep >= 0 && i_charm >= 0) { + // Trust Pythia: accept whatever charm meson it produced and + // overwrite the signature's pre-chosen (uniform) meson slot with + // Pythia's actual PID. Natural Lund-string fragmentation then + // carries the physical D-type distribution without rejection. + int pythia_pid = pythia_->event[i_charm].id(); + const_cast(record.signature) + .secondary_types[meson_index] = PdgToParticleType(pythia_pid); + + found_charm = true; + + // Extract 4-momenta from Pythia (in Pythia's frame: nu along +z) + Pythia8::Vec4 p_lep_pythia = pythia_->event[i_lep].p(); + Pythia8::Vec4 p_D_pythia = pythia_->event[i_charm].p(); + + // DIS kinematics for weighting, reconstructed from the outgoing lepton + // exactly as DifferentialCrossSection does (muon-reconstructed Bjorken + // x = Q2/(2 M E y)), so an optional differential spline fit in this + // convention closes against the sampler. NOT info.x2() (the parton + // momentum fraction), which is a different variable. + double E_lep = p_lep_pythia.e(); + double pythia_y = 1.0 - E_lep / E_nu; + Pythia8::Vec4 p_nu_pythia(0.0, 0.0, E_nu, E_nu); // (px,py,pz,e), nu along +z + Pythia8::Vec4 q4 = p_nu_pythia - p_lep_pythia; + double Q2_lep = -q4.m2Calc(); + double pythia_x = (pythia_y > 0.0) + ? Q2_lep / (2.0 * target_mass_ * E_nu * pythia_y) + : 0.0; + + // Store in interaction parameters for weighting + record.interaction_parameters.clear(); + record.interaction_parameters["energy"] = E_nu; + record.interaction_parameters["bjorken_x"] = pythia_x; + record.interaction_parameters["bjorken_y"] = pythia_y; + record.interaction_parameters["target_pdg"] = static_cast(target_pdg); + + // Rotate from Pythia frame (+z) to SIREN's neutrino direction + geom3::UnitVector3 z_dir = geom3::UnitVector3::zAxis(); + geom3::Rotation3 rot = geom3::rotationBetween(z_dir, nu_dir); + + // Helper lambda to convert Pythia Vec4 -> rotated rk::P4 + auto pythia_to_siren = [&](const Pythia8::Vec4 & pv, double mass) -> rk::P4 { + geom3::Vector3 mom(pv.px(), pv.py(), pv.pz()); + mom = rot * mom; + return rk::P4(mom, mass); + }; + + double lepton_mass = pythia_->event[i_lep].m(); + double D_mass = pythia_->event[i_charm].m(); + double remnant_mass = std::sqrt(std::max(0.0, + p_remnant.e() * p_remnant.e() - + p_remnant.px() * p_remnant.px() - + p_remnant.py() * p_remnant.py() - + p_remnant.pz() * p_remnant.pz())); + + rk::P4 p3 = pythia_to_siren(p_lep_pythia, lepton_mass); + rk::P4 p_D = pythia_to_siren(p_D_pythia, D_mass); + + // Remnant + geom3::Vector3 rem_mom(p_remnant.px(), p_remnant.py(), p_remnant.pz()); + rem_mom = rot * rem_mom; + rk::P4 p_rem(rem_mom, remnant_mass); + + // Set secondaries + std::vector & secondaries = record.GetSecondaryParticleRecords(); + siren::dataclasses::SecondaryParticleRecord & lepton = secondaries[lepton_index]; + siren::dataclasses::SecondaryParticleRecord & hadron = secondaries[hadron_index]; + siren::dataclasses::SecondaryParticleRecord & meson = secondaries[meson_index]; + + lepton.SetFourMomentum({p3.e(), p3.px(), p3.py(), p3.pz()}); + lepton.SetMass(lepton_mass); + lepton.SetHelicity(record.primary_helicity); + + hadron.SetFourMomentum({p_rem.e(), p_rem.px(), p_rem.py(), p_rem.pz()}); + hadron.SetMass(remnant_mass); + hadron.SetHelicity(record.target_helicity); + + meson.SetFourMomentum({p_D.e(), p_D.px(), p_D.py(), p_D.pz()}); + meson.SetMass(D_mass); + meson.SetHelicity(record.target_helicity); + + break; + } + } + + if (!found_charm) { + // Recoverable per-event failure: throw InjectionFailure so Injector:: + // GenerateEvent catches it and drops/retries the event instead of a plain + // std::runtime_error, which would abort the entire generation run. + throw siren::utilities::InjectionFailure("PythiaDISCrossSection::SampleFinalState: Failed to generate charm event after " + std::to_string(max_attempts) + " attempts"); + } +} + +} // namespace interactions +} // namespace siren diff --git a/projects/interactions/private/QuarkDISFromSpline.cxx b/projects/interactions/private/QuarkDISFromSpline.cxx new file mode 100644 index 000000000..9d9ec20e2 --- /dev/null +++ b/projects/interactions/private/QuarkDISFromSpline.cxx @@ -0,0 +1,899 @@ +#include "SIREN/interactions/QuarkDISFromSpline.h" + +#include // for map, opera... +#include // for set, opera... +#include // for array +#include // for pow, log10 +#include // for tie, opera... +#include // for allocator +#include // for basic_string +#include // for vector +#include // for assert +#include // for size_t +#include + +#include // for P4, Boost +#include // for Vector3 + +#include // for splinetable + +#include "SIREN/interactions/CrossSection.h" // for CrossSection +#include "SIREN/interactions/CharmCrossSectionHelpers.h" // shared charm-DIS helpers +#include "SIREN/dataclasses/InteractionRecord.h" // for Interactio... +#include "SIREN/dataclasses/Particle.h" // for Particle +#include "SIREN/utilities/Random.h" // for SIREN_random +#include "SIREN/utilities/Constants.h" // for electronMass +#include "SIREN/utilities/Errors.h" // for PythonImplementationError + + +namespace siren { +namespace interactions { + +namespace { +// Slow-rescaling helpers (lab frame, stationary nucleon target). +// E = primary neutrino lab energy +// M = target nucleon mass +// mc = charm-quark mass +inline double slowRescalingQ2(double xi, double y, double E, double M, double mc) { + return 2.0 * M * E * y * xi - mc * mc; +} +inline double xiToBjorkenX(double xi, double y, double E, double M, double mc) { + return xi - (mc * mc) / (2.0 * M * E * y); +} +inline double slowRescalingW2(double xi, double y, double E, double M, double mc) { + // W^2 = M^2 + 2 M E y (1 - xi) + m_c^2 = M^2 + (Q^2 + m_c^2)/xi - Q^2 + return M * M + 2.0 * M * E * y * (1.0 - xi) + mc * mc; +} + +///\brief Slow-rescaling kinematic check. +/// +/// Acceptance cuts: +/// xi in [1e-9, 1], y in [1e-9, 1 - m_lep/E], +/// Q^2 = 2 M E y xi - m_c^2 > 0 (charm threshold), +/// W^2 = M^2 + 2 M E y (1-xi) + m_c^2 > (M + M_D0)^2. +bool kinematicallyAllowed(double xi, double y, double E, double M, double m_lep) { + if (xi < 1e-9 || xi > 1.0) return false; + if (y < 1e-9) return false; + if (y > 1.0 - m_lep / E) return false; + + const double mc = siren::utilities::Constants::charmMass; + const double Mch = siren::utilities::Constants::D0Mass; + + const double Q2 = slowRescalingQ2(xi, y, E, M, mc); + if (Q2 <= 0.0) return false; + + const double W2 = slowRescalingW2(xi, y, E, M, mc); + if (W2 <= (M + Mch) * (M + Mch)) return false; + + // Transverse-momentum balance: the exchanged q must have a real transverse + // component pqy. Same q-decomposition the sampler uses, so this is the single + // predicate shared by the sampler and by DifferentialCrossSection; without it + // the density would be nonzero on points the sampler rejects (closure break). + // Massless primary (InitializeSignatures enforces isNeutrino): m1=0, |p1|=E. + // pqy^2 = momq^2 - pqx^2, + // pqx = (m_lep^2 + 2 P1^2 + Q2 + 2 E^2 (y-1))/(2 P1), momq^2 = P1^2 + Q2 + E^2(y^2-1) + const double P1 = E; + const double pqx = (m_lep * m_lep + 2.0 * P1 * P1 + Q2 + 2.0 * E * E * (y - 1.0)) / (2.0 * P1); + const double momq2 = P1 * P1 + Q2 + E * E * (y * y - 1.0); + const double pqy2 = momq2 - pqx * pqx; + if (pqy2 < 0.0) return false; + + return true; +} +} + +QuarkDISFromSpline::QuarkDISFromSpline() { + // initialize the pdf normalization and cdf table for the hadronization process + normalize_pdf(); + compute_cdf(); +} + +QuarkDISFromSpline::QuarkDISFromSpline(std::vector differential_data, std::vector total_data, int interaction, double target_mass, double minimum_Q2, std::set primary_types, std::set target_types, std::string units) : primary_types_(primary_types), target_types_(target_types), interaction_type_(interaction), target_mass_(target_mass), minimum_Q2_(minimum_Q2) { + normalize_pdf(); + compute_cdf(); + LoadFromMemory(differential_data, total_data); + SetInteractionType(interaction); + InitializeSignatures(); + SetUnits(units); +} + +QuarkDISFromSpline::QuarkDISFromSpline(std::vector differential_data, std::vector total_data, int interaction, double target_mass, double minimum_Q2, std::vector primary_types, std::vector target_types, std::string units) : primary_types_(primary_types.begin(), primary_types.end()), target_types_(target_types.begin(), target_types.end()), interaction_type_(interaction), target_mass_(target_mass), minimum_Q2_(minimum_Q2) { + normalize_pdf(); + compute_cdf(); + LoadFromMemory(differential_data, total_data); + SetInteractionType(interaction); + InitializeSignatures(); + SetUnits(units); +} + +QuarkDISFromSpline::QuarkDISFromSpline(std::string differential_filename, std::string total_filename, int interaction, double target_mass, double minimum_Q2, std::set primary_types, std::set target_types, std::string units) : primary_types_(primary_types), target_types_(target_types), interaction_type_(interaction), target_mass_(target_mass), minimum_Q2_(minimum_Q2) { + normalize_pdf(); + compute_cdf(); + LoadFromFile(differential_filename, total_filename); + SetInteractionType(interaction); + InitializeSignatures(); + SetUnits(units); +} + +QuarkDISFromSpline::QuarkDISFromSpline(std::string differential_filename, std::string total_filename, std::set primary_types, std::set target_types, std::string units) : primary_types_(primary_types), target_types_(target_types) { + normalize_pdf(); + compute_cdf(); + LoadFromFile(differential_filename, total_filename); + ReadParamsFromSplineTable(); + InitializeSignatures(); + SetUnits(units); +} + +QuarkDISFromSpline::QuarkDISFromSpline(std::string differential_filename, std::string total_filename, int interaction, double target_mass, double minimum_Q2, std::vector primary_types, std::vector target_types, std::string units) : primary_types_(primary_types.begin(), primary_types.end()), target_types_(target_types.begin(), target_types.end()), interaction_type_(interaction), target_mass_(target_mass), minimum_Q2_(minimum_Q2) { + normalize_pdf(); + compute_cdf(); + LoadFromFile(differential_filename, total_filename); + SetInteractionType(interaction); + InitializeSignatures(); + SetUnits(units); +} + +QuarkDISFromSpline::QuarkDISFromSpline(std::string differential_filename, std::string total_filename, std::vector primary_types, std::vector target_types, std::string units) : primary_types_(primary_types.begin(), primary_types.end()), target_types_(target_types.begin(), target_types.end()) { + normalize_pdf(); + compute_cdf(); + LoadFromFile(differential_filename, total_filename); + ReadParamsFromSplineTable(); + InitializeSignatures(); + SetUnits(units); +} + +void QuarkDISFromSpline::SetUnits(std::string units) { + unit = charm_xsec::UnitForString(units); +} + +void QuarkDISFromSpline::SetInteractionType(int interaction) { + interaction_type_ = interaction; +} + +bool QuarkDISFromSpline::equal(CrossSection const & other) const { + const QuarkDISFromSpline* x = dynamic_cast(&other); + // to do: include more features in the hadronization side to check equivalence + if(!x) + return false; + else + return + std::tie( + interaction_type_, + target_mass_, + minimum_Q2_, + signatures_, + primary_types_, + target_types_, + differential_cross_section_, + total_cross_section_) + == + std::tie( + x->interaction_type_, + x->target_mass_, + x->minimum_Q2_, + x->signatures_, + x->primary_types_, + x->target_types_, + x->differential_cross_section_, + x->total_cross_section_); +} + +void QuarkDISFromSpline::LoadFromFile(std::string dd_crossSectionFile, std::string total_crossSectionFile) { + + differential_cross_section_ = photospline::splinetable<>(dd_crossSectionFile.c_str()); + + if(differential_cross_section_.get_ndim()!=3 && differential_cross_section_.get_ndim()!=2) + throw std::runtime_error("cross section spline has " + std::to_string(differential_cross_section_.get_ndim()) + + " dimensions, should have either 3 (log10(E), log10(x), log10(y)) or 2 (log10(E), log10(y))"); + + total_cross_section_ = photospline::splinetable<>(total_crossSectionFile.c_str()); + + if(total_cross_section_.get_ndim() != 1) + throw std::runtime_error("Total cross section spline has " + std::to_string(total_cross_section_.get_ndim()) + + " dimensions, should have 1, log10(E)"); +} + +void QuarkDISFromSpline::LoadFromMemory(std::vector & differential_data, std::vector & total_data) { + differential_cross_section_.read_fits_mem(differential_data.data(), differential_data.size()); + total_cross_section_.read_fits_mem(total_data.data(), total_data.size()); +} + +double QuarkDISFromSpline::GetLeptonMass(siren::dataclasses::ParticleType lepton_type) { + return charm_xsec::GetLeptonMass(lepton_type); +} + +double QuarkDISFromSpline::getHadronMass(siren::dataclasses::ParticleType hadron_type) { + switch(hadron_type){ + case siren::dataclasses::ParticleType::D0: + return( siren::utilities::Constants::D0Mass); + case siren::dataclasses::ParticleType::D0Bar: + return( siren::utilities::Constants::D0Mass); + case siren::dataclasses::ParticleType::DPlus: + return( siren::utilities::Constants::DPlusMass); + case siren::dataclasses::ParticleType::DMinus: + return( siren::utilities::Constants::DPlusMass); + case siren::dataclasses::ParticleType::DsPlus: + return( siren::utilities::Constants::DsPlusMass); + case siren::dataclasses::ParticleType::DsMinus: + return( siren::utilities::Constants::DsMinusMass); + default: + return(0.0); + } +} + + +std::map QuarkDISFromSpline::getIndices(siren::dataclasses::InteractionSignature signature) { + // Initialize to -1 so an unmatched slot is detectable instead of being read + // back as an uninitialized index into the secondary arrays (UB / OOB). + int lepton_id = -1, hadron_id = -1, meson_id = -1; + for (size_t i = 0; i < signature.secondary_types.size(); i++){ + if (isLepton(signature.secondary_types[i])) { + lepton_id = i; + continue; + } else if (isD(signature.secondary_types[i])) { + meson_id = i; + continue; + } else { + hadron_id = i; + continue; + } + } + // A valid charm-DIS signature is (lepton, hadron, D-meson); a missing slot + // (e.g. the malformed {Hadrons,Hadrons,D} signature with no lepton) would + // otherwise leave an index at -1 and corrupt downstream array access. + if (lepton_id < 0 || hadron_id < 0 || meson_id < 0) { + throw std::runtime_error("QuarkDISFromSpline::getIndices: signature does not contain the expected (lepton, hadron, D-meson) triplet"); + } + return {{"lepton", lepton_id}, {"hadron", hadron_id}, {"meson", meson_id}}; +} + + +void QuarkDISFromSpline::ReadParamsFromSplineTable() { + // returns true if successfully read target mass + bool mass_good = differential_cross_section_.read_key("TARGETMASS", target_mass_); + // returns true if successfully read interaction type + bool int_good = differential_cross_section_.read_key("INTERACTION", interaction_type_); + // returns true if successfully read minimum Q2 + bool q2_good = differential_cross_section_.read_key("Q2MIN", minimum_Q2_); + + + if(!int_good) { + // assume DIS to preserve compatability with previous versions + interaction_type_ = 1; + } + + if(!q2_good) { + // assume 1 GeV^2 + minimum_Q2_ = 1; + } + + if(!mass_good) { + if(int_good) { + if(interaction_type_ == 1 or interaction_type_ == 2) { + target_mass_ = siren::utilities::Constants::isoscalarMass; + } else if(interaction_type_ == 3) { + target_mass_ = siren::utilities::Constants::electronMass; + } else { + throw std::runtime_error("Logic error. Interaction type is not 1, 2, or 3!"); + } + + } else { + if(differential_cross_section_.get_ndim() == 3) { + target_mass_ = siren::utilities::Constants::isoscalarMass; + } else if(differential_cross_section_.get_ndim() == 2) { + target_mass_ = siren::utilities::Constants::electronMass; + } else { + throw std::runtime_error("Logic error. Spline dimensionality is not 2, or 3!"); + } + } + } +} + +std::set QuarkDISFromSpline::DTypesForPrimary(siren::dataclasses::ParticleType primary) { + if (primary == siren::dataclasses::ParticleType::NuE + || primary == siren::dataclasses::ParticleType::NuMu + || primary == siren::dataclasses::ParticleType::NuTau) { + return {siren::dataclasses::Particle::ParticleType::D0, + siren::dataclasses::Particle::ParticleType::DPlus, + siren::dataclasses::Particle::ParticleType::DsPlus}; + } else if (primary == siren::dataclasses::ParticleType::NuEBar + || primary == siren::dataclasses::ParticleType::NuMuBar + || primary == siren::dataclasses::ParticleType::NuTauBar) { + return {siren::dataclasses::Particle::ParticleType::D0Bar, + siren::dataclasses::Particle::ParticleType::DMinus, + siren::dataclasses::Particle::ParticleType::DsMinus}; + } else { + throw std::runtime_error("DTypesForPrimary: Unknown neutrino primary type!"); + } +} + +void QuarkDISFromSpline::InitializeSignatures() { + signatures_.clear(); + for(auto primary_type : primary_types_) { + dataclasses::InteractionSignature signature; + signature.primary_type = primary_type; + if(not isNeutrino(primary_type)) { + throw std::runtime_error("This DIS implementation only supports neutrinos as primaries!"); + } + // first push back the charged lepton product + siren::dataclasses::ParticleType charged_lepton_product = charm_xsec::ChargedLeptonProduct(primary_type); + siren::dataclasses::ParticleType neutral_lepton_product = primary_type; + if(interaction_type_ == 1) { + signature.secondary_types.push_back(charged_lepton_product); + } else if(interaction_type_ == 2) { + signature.secondary_types.push_back(neutral_lepton_product); + } else if(interaction_type_ == 3) { + // interaction_type 3 (electron / e-target) has no outgoing lepton in + // this charm convention and would emit a malformed {Hadrons,Hadrons,D} + // signature with no lepton index. The differential cross section and + // sampler both require a lepton to form q = p1 - p3, so reject it at + // construction rather than create an unusable signature. + throw std::runtime_error("QuarkDISFromSpline: interaction_type 3 (e-target) not supported for charm D-meson production; use 1 (CC) or 2 (NC)"); + } else { + throw std::runtime_error("InitializeSignatures: Unkown interaction type!"); + } + // now push back the hadron product + signature.secondary_types.push_back(siren::dataclasses::ParticleType::Hadrons); + std::set d_types_local = DTypesForPrimary(primary_type); + for (auto meson_type : d_types_local) { + dataclasses::InteractionSignature full_signature = signature; + full_signature.secondary_types.push_back(meson_type); + // and finally set the target type and push back the entire signature as well as sig by target + for(auto target_type : target_types_) { + full_signature.target_type = target_type; + + signatures_.push_back(full_signature); + + std::pair key(primary_type, target_type); + signatures_by_parent_types_[key].push_back(full_signature); + } + } + } +} + +void QuarkDISFromSpline::normalize_pdf() { + if (fragmentation_integral == 0){ + std::function integrand = [&] (double x) -> double { + return (0.8 / x ) / (std::pow(1 - (1 / x) - (0.2 / (1 - x)), 2)); + }; + fragmentation_integral = siren::utilities::rombergIntegrate(integrand, 0.001, 0.999); + } else { + return; + } +} + +void QuarkDISFromSpline::compute_cdf() { + // first set the z nodes + std::vector zspline; + for (int i = 0; i < 100; ++i) { + zspline.push_back(0.01 + i * (0.99-0.01) / 100 ); + } + + // declare the cdf vectors + std::vector cdf_vector; + std::vector cdf_z_nodes; + std::vector pdf_vector; + + cdf_z_nodes.push_back(0); + cdf_vector.push_back(0); + pdf_vector.push_back(0); + + // compute the spline table + for (int i = 0; i < zspline.size(); ++i) { + if (i == 0) { + double cur_z = zspline[i]; + double cur_pdf = sample_pdf(cur_z); + double area = cur_z * cur_pdf * 0.5; + pdf_vector.push_back(cur_pdf); + cdf_vector.push_back(area); + cdf_z_nodes.push_back(cur_z); + continue; + } + double cur_z = zspline[i]; + double cur_pdf = sample_pdf(cur_z); + double area = 0.5 * (pdf_vector[i - 1] + cur_pdf) * (zspline[i] - zspline[i - 1]); + pdf_vector.push_back(cur_pdf); + cdf_z_nodes.push_back(cur_z); + cdf_vector.push_back(area + cdf_vector.back()); + } + + cdf_z_nodes.push_back(1); + cdf_vector.push_back(1); + pdf_vector.push_back(0); + + + // set the spline table + siren::utilities::TableData1D inverse_cdf_data; + inverse_cdf_data.x = cdf_vector; + inverse_cdf_data.f = cdf_z_nodes; + + inverseCdfTable = siren::utilities::Interpolator1D(inverse_cdf_data); + + return; +} + +double QuarkDISFromSpline::sample_pdf(double x) const { + return (0.8 / x ) / (std::pow(1 - (1 / x) - (0.2 / (1 - x)), 2)) / fragmentation_integral; +} + +double QuarkDISFromSpline::TotalCrossSection(dataclasses::InteractionRecord const & interaction) const { + siren::dataclasses::ParticleType primary_type = interaction.signature.primary_type; + rk::P4 p1(geom3::Vector3(interaction.primary_momentum[1], interaction.primary_momentum[2], interaction.primary_momentum[3]), interaction.primary_mass); + double primary_energy; + primary_energy = interaction.primary_momentum[0]; + // if we are below threshold, return 0 + if(primary_energy < InteractionThreshold(interaction)) { + return 0; + } + double total_xs = TotalCrossSection(primary_type, primary_energy); + // Apply fragmentation fraction for the specific D meson in this signature + // so that summing over signatures (D0 + D+) gives the correct total + for (auto const & sec_type : interaction.signature.secondary_types) { + if (siren::dataclasses::isD(sec_type)) { + total_xs *= FragmentationFraction(sec_type); + break; + } + } + return total_xs; +} + +double QuarkDISFromSpline::TotalCrossSection(siren::dataclasses::ParticleType primary_type, double primary_energy) const { + if(not primary_types_.count(primary_type)) { + throw std::runtime_error("Supplied primary not supported by cross section!"); + } + double log_energy = log10(primary_energy); + + if(log_energy < total_cross_section_.lower_extent(0) + or log_energy > total_cross_section_.upper_extent(0)) { + throw std::runtime_error("Interaction energy ("+ std::to_string(primary_energy) + + ") out of cross section table range: [" + + std::to_string(pow(10.,total_cross_section_.lower_extent(0))) + " GeV," + + std::to_string(pow(10.,total_cross_section_.upper_extent(0))) + " GeV]"); + } + + int center; + total_cross_section_.searchcenters(&log_energy, ¢er); + + double log_xs = total_cross_section_.ndsplineeval(&log_energy, ¢er, 0); + + return unit * std::pow(10.0, log_xs); +} + +double QuarkDISFromSpline::DifferentialCrossSection(dataclasses::InteractionRecord const & interaction) const { + rk::P4 p1(geom3::Vector3(interaction.primary_momentum[1], interaction.primary_momentum[2], interaction.primary_momentum[3]), interaction.primary_mass); + rk::P4 p2(geom3::Vector3(0, 0, 0), interaction.target_mass); + double primary_energy; + primary_energy = interaction.primary_momentum[0]; + assert(interaction.signature.secondary_types.size() == 3); + std::map secondaries = getIndices(interaction.signature); + unsigned int lepton_index = secondaries["lepton"]; + unsigned int hadron_index = secondaries["hadron"]; + unsigned int meson_index = secondaries["meson"]; + + std::array const & mom3 = interaction.secondary_momenta[lepton_index]; + std::array const & mom_x = interaction.secondary_momenta[hadron_index]; + std::array const & mom_d = interaction.secondary_momenta[meson_index]; + + rk::P4 p3(geom3::Vector3(mom3[1], mom3[2], mom3[3]), interaction.secondary_masses[lepton_index]); + rk::P4 p_x(geom3::Vector3(mom_x[1], mom_x[2], mom_x[3]), interaction.secondary_masses[hadron_index]); + rk::P4 p_d(geom3::Vector3(mom_d[1], mom_d[2], mom_d[3]), interaction.secondary_masses[meson_index]); + rk::P4 p4 = p_x + p_d; // this assume that we are working in a good frame where the hadronization vertex has 4-momentum conserved + rk::P4 q = p1 - p3; + // however p4 is not used in computation here so we should be fine... + + double Q2 = -q.dot(q); + double lepton_mass = GetLeptonMass(interaction.signature.secondary_types[lepton_index]); + + const double mc = siren::utilities::Constants::charmMass; + double y = 1.0 - p2.dot(p3) / p2.dot(p1); + // xi from inverting Q^2 = 2 M E y xi - m_c^2; guard against y <= 0: + double xi = (y > 0.0) + ? (Q2 + mc * mc) / (2.0 * primary_energy * target_mass_ * y) + : 0.0; + double log_energy = log10(primary_energy); + std::array centers; + + bool use_sample_kinematics = (xi > 0.0 && y > 0.0 && Q2 >= minimum_Q2_); + if (use_sample_kinematics) { + std::array coordinates{{log_energy, log10(xi), log10(y)}}; + use_sample_kinematics = + kinematicallyAllowed(xi, y, primary_energy, target_mass_, lepton_mass) + && differential_cross_section_.searchcenters(coordinates.data(), centers.data()); + } + + if (!use_sample_kinematics) { + double E1_lab = interaction.interaction_parameters.at("energy"); + // Fallback: trust stored xi (records sampled by this class always have bjorken_xi). + // If absent, std::out_of_range is intentional: this class is xi-y only. + xi = interaction.interaction_parameters.at("bjorken_xi"); + y = interaction.interaction_parameters.at("bjorken_y"); + Q2 = slowRescalingQ2(xi, y, E1_lab, target_mass_, mc); + } + return DifferentialCrossSection(primary_energy, xi, y, lepton_mass, Q2); +} + +double QuarkDISFromSpline::DifferentialCrossSection(double energy, double xi, double y, double secondary_lepton_mass, double Q2) const { + double log_energy = log10(energy); + // Out of spline support -> raise, never silently return 0: a silent zero on a + // genuinely sampled event would bias that event's weight to zero. + if (log_energy < differential_cross_section_.lower_extent(0) + || log_energy > differential_cross_section_.upper_extent(0)) { + throw std::runtime_error("QuarkDISFromSpline: energy " + std::to_string(energy) + + " GeV is outside the differential spline energy range [" + + std::to_string(std::pow(10.0, differential_cross_section_.lower_extent(0))) + ", " + + std::to_string(std::pow(10.0, differential_cross_section_.upper_extent(0))) + + "] GeV."); + } + if (xi <= 0 || xi >= 1 || y <= 0 || y >= 1) { + throw std::runtime_error("QuarkDISFromSpline: unphysical (xi=" + + std::to_string(xi) + ", y=" + std::to_string(y) + ") outside (0, 1)."); + } + + if (std::isnan(Q2)) { + Q2 = slowRescalingQ2(xi, y, energy, target_mass_, + siren::utilities::Constants::charmMass); + } + if (Q2 < minimum_Q2_) { + return 0; + } + if (!kinematicallyAllowed(xi, y, energy, target_mass_, secondary_lepton_mass)) { + return 0; + } + std::array coordinates{{log_energy, log10(xi), log10(y)}}; + std::array centers; + if (!differential_cross_section_.searchcenters(coordinates.data(), centers.data())) { + throw std::runtime_error("QuarkDISFromSpline: (xi=" + std::to_string(xi) + + ", y=" + std::to_string(y) + ") at E=" + std::to_string(energy) + + " GeV is outside the differential spline (xi, y) grid."); + } + double result = pow(10., differential_cross_section_.ndsplineeval(coordinates.data(), centers.data(), 0)); + assert(result >= 0); + return unit * result; +} + +double QuarkDISFromSpline::InteractionThreshold(dataclasses::InteractionRecord const & interaction) const { + // Consider implementing DIS thershold at some point + return 0; +} + +void QuarkDISFromSpline::SampleFinalState(dataclasses::CrossSectionDistributionRecord & record, std::shared_ptr random) const { + // first obtain the indices from secondaries + std::map secondary_indices = getIndices(record.signature); + unsigned int lepton_index = secondary_indices["lepton"]; + unsigned int hadron_index = secondary_indices["hadron"]; + unsigned int meson_index = secondary_indices["meson"]; + + // Uses Metropolis-Hastings Algorithm! + // useful for cases where we don't know the supremum of our distribution, and the distribution is multi-dimensional + if (differential_cross_section_.get_ndim() != 3) { + throw std::runtime_error("I expected 3 dimensions in the cross section spline, but got " + std::to_string(differential_cross_section_.get_ndim()) +". Maybe your fits file doesn't have the right 'INTERACTION' key?"); + } + rk::P4 p1(geom3::Vector3(record.primary_momentum[1], record.primary_momentum[2], record.primary_momentum[3]), record.primary_mass); + rk::P4 p2(geom3::Vector3(0, 0, 0), target_mass_); + + // we assume that: + // the target is stationary so its energy is just its mass + // the incoming neutrino is massless, so its kinetic energy is its total energy + // double s = target_mass_ * trecord.secondary_momentarget_mass_ + 2 * target_mass_ * primary_energy; + // double s = std::pow(rk::invMass(p1, p2), 2); + + double primary_energy; + rk::P4 p1_lab; + rk::P4 p2_lab; + p1_lab = p1; + p2_lab = p2; + primary_energy = p1_lab.e(); + + // correctly assign lepton, hadron and meson index + double m = GetLeptonMass(record.signature.secondary_types[lepton_index]); + + double m1 = record.primary_mass; + double m3 = m; + double E1_lab = p1_lab.e(); + double E2_lab = p2_lab.e(); + + const double mc = siren::utilities::Constants::charmMass; + const double Mch = siren::utilities::Constants::D0Mass; + const double M_targ = target_mass_; + const double E_nu = primary_energy; + const double Q2_min = minimum_Q2_; + + const double yMax = 1.0 - m / E_nu; + const double W2_thr = (M_targ + Mch) * (M_targ + Mch); + const double yMin = (W2_thr - M_targ * M_targ + Q2_min) / (2.0 * M_targ * E_nu); + const double xiMin = (mc * mc + Q2_min) / (2.0 * M_targ * E_nu * yMax); + + if (xiMin <= 0.0 || yMin <= 0.0 || yMax <= 0.0) { + throw std::runtime_error( + "QuarkDISFromSpline: non-positive sampling-bound (xiMin=" + + std::to_string(xiMin) + ", yMin=" + std::to_string(yMin) + + ", yMax=" + std::to_string(yMax) + ")"); + } + if (xiMin >= 1.0 || yMin >= yMax) { + throw std::runtime_error( + "QuarkDISFromSpline: primary energy below slow-rescaling charm threshold " + "(xiMin=" + std::to_string(xiMin) + ", yMin=" + std::to_string(yMin) + + ", yMax=" + std::to_string(yMax) + ")"); + } + + const double logXiMin = std::log10(xiMin); + const double logYMin = std::log10(yMin); + const double logYMax = std::log10(yMax); + + bool accept; + + // kin_vars and its twin are 3-vectors containing [nu-energy, xi, Bjorken Y] + std::array kin_vars, test_kin_vars; + + // centers of the cross section spline tales. + std::array spline_table_center, test_spline_table_center; + + // values of cross_section from the splines. By * Bxi * Spline(E,xi,y) + double cross_section, test_cross_section; + + // No matter what, we're evaluating at this specific energy. + kin_vars[0] = test_kin_vars[0] = log10(primary_energy); + + // check preconditions + if(kin_vars[0] < differential_cross_section_.lower_extent(0) + || kin_vars[0] > differential_cross_section_.upper_extent(0)) + throw std::runtime_error("Interaction energy out of cross section table range: [" + + std::to_string(pow(10.,differential_cross_section_.lower_extent(0))) + " GeV," + + std::to_string(pow(10.,differential_cross_section_.upper_extent(0))) + " GeV]"); + + // sample an intial point + do { + // rejection sample a point which is kinematically allowed by calculation limits + double trialQ; + double trials = 0; + double xi_trial = 0.0, y_trial = 0.0; + do { + // Per-event recoverable failure: drop this event (InjectionFailure is + // caught by the Injector) rather than aborting the whole run. + if (trials >= 100) throw siren::utilities::InjectionFailure("QuarkDISFromSpline: initial rejection sampling failed to find a kinematically allowed point in 100 trials"); + trials += 1; + kin_vars[1] = random->Uniform(logXiMin, 0.0); + kin_vars[2] = random->Uniform(logYMin, logYMax); + xi_trial = std::pow(10., kin_vars[1]); + y_trial = std::pow(10., kin_vars[2]); + trialQ = slowRescalingQ2(xi_trial, y_trial, E_nu, M_targ, mc); + } while (trialQ < minimum_Q2_ + || !kinematicallyAllowed(xi_trial, y_trial, E_nu, M_targ, m)); + + accept = true; + //sanity check: demand that the sampled point be within the table extents + if(kin_vars[1] < differential_cross_section_.lower_extent(1) + || kin_vars[1] > differential_cross_section_.upper_extent(1)) { + accept = false; + } + if(kin_vars[2] < differential_cross_section_.lower_extent(2) + || kin_vars[2] > differential_cross_section_.upper_extent(2)) { + accept = false; + } + + if(accept) { + // finds the centers in the cross section spline table, returns true if it's successful + // also sets the centers + accept = differential_cross_section_.searchcenters(kin_vars.data(),spline_table_center.data()); + } + } while(!accept); + + //TODO: better proposal distribution? + double measure = pow(10., kin_vars[1] + kin_vars[2]); // Bxi * By + + // Bxi * By * xs(E, xi, y) + // evalutates the differential spline at that point + cross_section = measure*pow(10., differential_cross_section_.ndsplineeval(kin_vars.data(), spline_table_center.data(), 0)); + + // this is the magic part. Metropolis Hastings Algorithm. + // MCMC method! + const size_t burnin = 40; // converges to the correct distribution over multiple samplings. + // big number means more accurate, but slower + for(size_t j = 0; j <= burnin; j++) { + // repeat the sampling from above to get a new valid point + double trialQ; + double xi_trial = 0.0, y_trial = 0.0; + int burnin_trials = 0; + do { + // Per-event recoverable failure: drop this event (InjectionFailure is + // caught by the Injector) rather than aborting the whole run. + if (++burnin_trials >= 100) + throw siren::utilities::InjectionFailure("QuarkDISFromSpline: burn-in proposal failed to find allowed point in 100 trials"); + test_kin_vars[1] = random->Uniform(logXiMin, 0.0); + test_kin_vars[2] = random->Uniform(logYMin, logYMax); + xi_trial = std::pow(10., test_kin_vars[1]); + y_trial = std::pow(10., test_kin_vars[2]); + trialQ = slowRescalingQ2(xi_trial, y_trial, E_nu, M_targ, mc); + } while (trialQ < minimum_Q2_ + || !kinematicallyAllowed(xi_trial, y_trial, E_nu, M_targ, m)); + + accept = true; + if(test_kin_vars[1] < differential_cross_section_.lower_extent(1) + || test_kin_vars[1] > differential_cross_section_.upper_extent(1)) + accept = false; + if(test_kin_vars[2] < differential_cross_section_.lower_extent(2) + || test_kin_vars[2] > differential_cross_section_.upper_extent(2)) + accept = false; + if(!accept) + continue; + + accept = differential_cross_section_.searchcenters(test_kin_vars.data(), test_spline_table_center.data()); + if(!accept) + continue; + + double measure = pow(10., test_kin_vars[1] + test_kin_vars[2]); + double eval = differential_cross_section_.ndsplineeval(test_kin_vars.data(), test_spline_table_center.data(), 0); + if(std::isnan(eval)) + continue; + test_cross_section = measure * pow(10., eval); + + double odds = (test_cross_section / cross_section); + accept = (cross_section == 0 || (odds > 1.) || random->Uniform(0, 1) < odds); + + if(accept) { + kin_vars = test_kin_vars; + cross_section = test_cross_section; + } + } + + // scaling down to handle numerical issues + double final_xi = std::pow(10., kin_vars[1]); + double final_y = pow(10., kin_vars[2]); + record.interaction_parameters.clear(); + record.interaction_parameters["energy"] = E1_lab; + record.interaction_parameters["bjorken_xi"] = final_xi; + record.interaction_parameters["bjorken_y"] = final_y; + record.interaction_parameters["bjorken_x"] = + xiToBjorkenX(final_xi, final_y, E1_lab, target_mass_, + siren::utilities::Constants::charmMass); + + double Q2 = slowRescalingQ2(final_xi, final_y, E1_lab, target_mass_, + siren::utilities::Constants::charmMass); + // Closed form for the exchanged q. p1x_lab is the 3-momentum MAGNITUDE + // P1 = |p1_lab|. Substituting P1^2 = E1^2 - m1^2 cancels the dominant ~E1^2 + // terms analytically (the naive pqx/momq^2 lose precision in pqy^2 = momq^2-pqx^2): + // pqx = (m3^2 - m1^2 + Q2 + 2 E1^2 y)/(2 P1), momq^2 = Q2 + E1^2 y^2 + double p1x_lab = std::sqrt(p1_lab.px() * p1_lab.px() + p1_lab.py() * p1_lab.py() + p1_lab.pz() * p1_lab.pz()); + double pqx_lab = (m3 * m3 - m1 * m1 + Q2 + 2.0 * E1_lab * E1_lab * final_y) / (2.0 * p1x_lab); + double momq2_lab = Q2 + E1_lab * E1_lab * final_y * final_y; + double pqy2_lab = momq2_lab - pqx_lab * pqx_lab; + // Clamp tiny-negative pqy^2 from edge roundoff to 0; genuinely-forbidden + // points (pqy^2 < 0 in exact arithmetic) are already excluded upstream by + // kinematicallyAllowed, so a large-magnitude negative here signals a + // sampler/predicate inconsistency and is a hard error. + if (pqy2_lab < 0.0) { + if (pqy2_lab > -1e-9 * std::max(momq2_lab, 1.0)) { + pqy2_lab = 0.0; + } else { + throw(siren::utilities::InjectionFailure( + "QuarkDISFromSpline::SampleFinalState: pqy^2 < 0 for a " + "kinematicallyAllowed point (sampler/predicate inconsistency)")); + } + } + double pqy_lab = std::sqrt(pqy2_lab); + double Eq_lab = E1_lab * final_y; + + geom3::UnitVector3 x_dir = geom3::UnitVector3::xAxis(); + geom3::Vector3 p1_mom = p1_lab.momentum(); + geom3::UnitVector3 p1_lab_dir = p1_mom.direction(); + geom3::Rotation3 x_to_p1_lab_rot = geom3::rotationBetween(x_dir, p1_lab_dir); + + double phi = random->Uniform(0, 2.0 * M_PI); + geom3::Rotation3 rand_rot(p1_lab_dir, phi); + + rk::P4 pq_lab(Eq_lab, geom3::Vector3(pqx_lab, pqy_lab, 0)); + pq_lab.rotate(x_to_p1_lab_rot); + pq_lab.rotate(rand_rot); + + rk::P4 p3_lab((p1_lab - pq_lab).momentum(), m3); + + + + // ############################################# + // New hadronization scheme: includes partonic cross section sampling and slow rescaling for charm mass effects + // ############################################## + + const double xi = final_xi; // sampled directly in slow-rescaling + if (xi >= 1.0) { + throw(siren::utilities::InjectionFailure("xi >= 1.0; sampled slow-rescaling xi past unity")); + } + rk::P4 p_parton(geom3::Vector3(0, 0, 0), xi * target_mass_); // parton at rest: (xi*M, 0, 0, 0) + rk::P4 p4_lab = p_parton + pq_lab; // struck charm = xi*p2 + q + rk::P4 p_spectator((1.0 - xi) * target_mass_, geom3::Vector3(0, 0, 0)); // spectator: ((1-xi)*M, 0,0, 0) + + rk::P4 p3; + rk::P4 p4; + p3 = p3_lab; // now we have our lepton momentum set, which should not be modified from here on + p4 = p4_lab; // momentum of the virtual charm + + // D meson fragmentation: D gets fraction z of charm quark momentum + double mCH = getHadronMass(record.signature.secondary_types[meson_index]); + double p_charm_mag = std::sqrt(p4_lab.px()*p4_lab.px() + p4_lab.py()*p4_lab.py() + p4_lab.pz()*p4_lab.pz()); + geom3::Vector3 p4_mom = p4_lab.momentum(); + geom3::UnitVector3 p4_dir = p4_mom.direction(); + + rk::P4 p4CH, p4X; + int max_sampling = 500; + int sampling = 0; + do { + sampling += 1; + if (sampling > max_sampling) { + throw(siren::utilities::InjectionFailure("Failed to sample hadronization!")); + } + double z = inverseCdfTable(random->Uniform(0, 1)); + double pCH_mag = z * p_charm_mag; + p4CH = rk::P4(geom3::Vector3(p4_dir * pCH_mag), mCH); + p4X = p_spectator + p4_lab - p4CH; + } while (p4X.dot(p4X) < 0); + + // Save final state kinematics into the record's SecondaryParticleRecord vector + // (not directly into secondary_momenta); the caller runs + // CrossSectionDistributionRecord::Finalize to populate a finalized record. + std::vector & secondaries = record.GetSecondaryParticleRecords(); + siren::dataclasses::SecondaryParticleRecord & lepton = secondaries[lepton_index]; + siren::dataclasses::SecondaryParticleRecord & hadron = secondaries[hadron_index]; + siren::dataclasses::SecondaryParticleRecord & meson = secondaries[meson_index]; + + lepton.SetFourMomentum({p3.e(), p3.px(), p3.py(), p3.pz()}); + lepton.SetMass(p3.m()); + lepton.SetHelicity(record.primary_helicity); + hadron.SetFourMomentum({p4X.e(), p4X.px(), p4X.py(), p4X.pz()}); + // Use the already-validated dot() result instead of P4::m(), which + // recomputes msq from e^2 - p_.lengthSquared() and can disagree with + // dot() by FP roundoff (~1e-15) -- the assert in m() would then fire + // even though the do-while above accepted p4X. + const double p4X_msq = p4X.dot(p4X); + hadron.SetMass(p4X_msq > 0.0 ? std::sqrt(p4X_msq) : 0.0); + hadron.SetHelicity(record.target_helicity); + meson.SetFourMomentum({p4CH.e(), p4CH.px(), p4CH.py(), p4CH.pz()}); +} + +double QuarkDISFromSpline::FragmentationFraction(siren::dataclasses::Particle::ParticleType secondary) const { + return charm_xsec::FragmentationFraction(secondary); +} + +// FinalStateProbability = dxs/txs. Normalized only if the external total-xs spline +// integrates the same truncated (xi,y) domain (charm-threshold, W2, Q2>=minimum_Q2_, +// TARGETMASS) as the differential spline. See the contract blocks in the header. +// Fragmentation fraction is applied inside TotalCrossSection(record). +double QuarkDISFromSpline::FinalStateProbability(dataclasses::InteractionRecord const & interaction) const { + // first compute the differential and total cross section + double dxs = DifferentialCrossSection(interaction); + double txs = TotalCrossSection(interaction); + // fragmentation fraction is now applied inside TotalCrossSection + if(dxs == 0) { + return 0.0; + } else { + return dxs / txs; + } +} + +std::vector QuarkDISFromSpline::GetPossiblePrimaries() const { + return charm_xsec::ToVector(primary_types_); +} + +std::vector QuarkDISFromSpline::GetPossibleTargetsFromPrimary(siren::dataclasses::ParticleType primary_type) const { + return charm_xsec::ToVector(target_types_); +} + +std::vector QuarkDISFromSpline::GetPossibleSignatures() const { + return std::vector(signatures_.begin(), signatures_.end()); +} + +std::vector QuarkDISFromSpline::GetPossibleTargets() const { + return charm_xsec::ToVector(target_types_); +} + +std::vector QuarkDISFromSpline::GetPossibleSignaturesFromParents(siren::dataclasses::ParticleType primary_type, siren::dataclasses::ParticleType target_type) const { + return charm_xsec::SignaturesForParents(signatures_by_parent_types_, primary_type, target_type); +} + +// Density covers only (xi,y); the independently-sampled fragmentation z and +// azimuth phi cancel in the weight ratio only in the unbiased configuration. +// See the UNBIASED-ONLY CONTRACT in the header. Biasing D kinematics is unsupported. +std::vector QuarkDISFromSpline::DensityVariables() const { + return std::vector{"Bjorken xi", "Bjorken y"}; +} + +} // namespace interactions +} // namespace siren diff --git a/projects/interactions/private/pybindings/CharmMesonDecay.h b/projects/interactions/private/pybindings/CharmMesonDecay.h new file mode 100644 index 000000000..224ef9ad2 --- /dev/null +++ b/projects/interactions/private/pybindings/CharmMesonDecay.h @@ -0,0 +1,34 @@ +#include +#include +#include + +#include +#include +#include + +#include "../../public/SIREN/interactions/CrossSection.h" +#include "../../public/SIREN/interactions/Decay.h" +#include "../../public/SIREN/interactions/CharmMesonDecay.h" + +#include "../../public/SIREN/interactions/pyDecay.h" +#include "../../../dataclasses/public/SIREN/dataclasses/Particle.h" +#include "../../../geometry/public/SIREN/geometry/Geometry.h" +#include "../../../utilities/public/SIREN/utilities/Random.h" + +void register_CharmMesonDecay(pybind11::module_ & m) { + using namespace pybind11; + using namespace siren::interactions; + + class_, Decay> charmmesondecay(m, "CharmMesonDecay"); + + charmmesondecay + + .def(init<>()) + .def(init(), + arg("primary_type")) + .def(self == self) + .def("SampleFinalState",&CharmMesonDecay::SampleFinalState) + .def("GetPossibleSignatures",&CharmMesonDecay::GetPossibleSignatures) + .def("GetPossibleSignaturesFromParent",&CharmMesonDecay::GetPossibleSignaturesFromParent); + +} diff --git a/projects/interactions/private/pybindings/CharmMesonDecay3Body.h b/projects/interactions/private/pybindings/CharmMesonDecay3Body.h new file mode 100644 index 000000000..560251168 --- /dev/null +++ b/projects/interactions/private/pybindings/CharmMesonDecay3Body.h @@ -0,0 +1,34 @@ +#include +#include +#include + +#include +#include +#include + +#include "../../public/SIREN/interactions/CrossSection.h" +#include "../../public/SIREN/interactions/Decay.h" +#include "../../public/SIREN/interactions/CharmMesonDecay3Body.h" + +#include "../../public/SIREN/interactions/pyDecay.h" +#include "../../../dataclasses/public/SIREN/dataclasses/Particle.h" +#include "../../../geometry/public/SIREN/geometry/Geometry.h" +#include "../../../utilities/public/SIREN/utilities/Random.h" + +void register_CharmMesonDecay3Body(pybind11::module_ & m) { + using namespace pybind11; + using namespace siren::interactions; + + class_, Decay> charmmesondecay3body(m, "CharmMesonDecay3Body"); + + charmmesondecay3body + + .def(init<>()) + .def(init(), + arg("primary_type")) + .def(self == self) + .def("SampleFinalState",&CharmMesonDecay3Body::SampleFinalState) + .def("GetPossibleSignatures",&CharmMesonDecay3Body::GetPossibleSignatures) + .def("GetPossibleSignaturesFromParent",&CharmMesonDecay3Body::GetPossibleSignaturesFromParent); + +} diff --git a/projects/interactions/private/pybindings/DMesonELoss.h b/projects/interactions/private/pybindings/DMesonELoss.h new file mode 100644 index 000000000..ce88c6463 --- /dev/null +++ b/projects/interactions/private/pybindings/DMesonELoss.h @@ -0,0 +1,38 @@ +#include +#include +#include + +#include +#include +#include + +#include "../../public/SIREN/interactions/CrossSection.h" +// Include the public class header directly so this pybinding header is +// self-contained regardless of include order in its includer. +#include "../../public/SIREN/interactions/DMesonELoss.h" +#include "../../../dataclasses/public/SIREN/dataclasses/Particle.h" +#include "../../../dataclasses/public/SIREN/dataclasses/InteractionRecord.h" +#include "../../../dataclasses/public/SIREN/dataclasses/InteractionSignature.h" +#include "../../../geometry/public/SIREN/geometry/Geometry.h" +#include "../../../utilities/public/SIREN/utilities/Random.h" + +void register_DMesonELoss(pybind11::module_ & m) { + using namespace pybind11; + using namespace siren::interactions; + + class_, CrossSection> dmesoneloss(m, "DMesonELoss"); + + dmesoneloss + .def(init<>()) + .def(self == self) + .def("TotalCrossSection",overload_cast(&DMesonELoss::TotalCrossSection, const_)) + .def("TotalCrossSection",overload_cast(&DMesonELoss::TotalCrossSection, const_)) + .def("InteractionThreshold",&DMesonELoss::InteractionThreshold) + .def("GetPossibleTargets",&DMesonELoss::GetPossibleTargets) + .def("GetPossibleTargetsFromPrimary",&DMesonELoss::GetPossibleTargetsFromPrimary) + .def("GetPossiblePrimaries",&DMesonELoss::GetPossiblePrimaries) + .def("GetPossibleSignatures",&DMesonELoss::GetPossibleSignatures) + .def("GetPossibleSignaturesFromParents",&DMesonELoss::GetPossibleSignaturesFromParents) + .def("FinalStateProbability",&DMesonELoss::FinalStateProbability); +} + diff --git a/projects/interactions/private/pybindings/PythiaDISCrossSection.h b/projects/interactions/private/pybindings/PythiaDISCrossSection.h new file mode 100644 index 000000000..2d78fae47 --- /dev/null +++ b/projects/interactions/private/pybindings/PythiaDISCrossSection.h @@ -0,0 +1,96 @@ +#include +#include +#include + +#include +#include +#include + +#include "../../public/SIREN/interactions/CrossSection.h" +#include "../../../dataclasses/public/SIREN/dataclasses/Particle.h" +#include "../../../dataclasses/public/SIREN/dataclasses/InteractionRecord.h" +#include "../../../dataclasses/public/SIREN/dataclasses/InteractionSignature.h" +#include "../../../geometry/public/SIREN/geometry/Geometry.h" +#include "../../../utilities/public/SIREN/utilities/Random.h" + +#ifdef SIREN_HAS_PYTHIA8 +#include "../../public/SIREN/interactions/PythiaDISCrossSection.h" + +void register_PythiaDISCrossSection(pybind11::module_ & m) { + using namespace pybind11; + using namespace siren::interactions; + + class_, CrossSection> pythiadis(m, "PythiaDISCrossSection"); + + pythiadis + + .def(init<>()) + .def(init, + std::set, + std::string, std::string, std::string>(), + arg("differential_filename"), + arg("total_filename"), + arg("interaction_type"), + arg("target_mass"), + arg("minimum_Q2"), + arg("primary_types"), + arg("target_types"), + arg("pythia_data_path"), + arg("pdf_set") = std::string("LHAPDF6:HERAPDF20_NLO_EIG"), + arg("units") = std::string("cm")) + .def(init, + std::vector, + std::string, std::string, std::string>(), + arg("differential_filename"), + arg("total_filename"), + arg("interaction_type"), + arg("target_mass"), + arg("minimum_Q2"), + arg("primary_types"), + arg("target_types"), + arg("pythia_data_path"), + arg("pdf_set") = std::string("LHAPDF6:HERAPDF20_NLO_EIG"), + arg("units") = std::string("cm")) + .def(self == self) + .def("TotalCrossSection",overload_cast(&PythiaDISCrossSection::TotalCrossSection, const_)) + .def("TotalCrossSection",overload_cast(&PythiaDISCrossSection::TotalCrossSection, const_)) + .def("DifferentialCrossSection",overload_cast(&PythiaDISCrossSection::DifferentialCrossSection, const_)) + .def("DifferentialCrossSection",overload_cast(&PythiaDISCrossSection::DifferentialCrossSection, const_)) + .def("InteractionThreshold",&PythiaDISCrossSection::InteractionThreshold) + .def("FragmentationFraction",&PythiaDISCrossSection::FragmentationFraction) + .def("GetPossibleTargets",&PythiaDISCrossSection::GetPossibleTargets) + .def("GetPossibleTargetsFromPrimary",&PythiaDISCrossSection::GetPossibleTargetsFromPrimary) + .def("GetPossiblePrimaries",&PythiaDISCrossSection::GetPossiblePrimaries) + .def("GetPossibleSignatures",&PythiaDISCrossSection::GetPossibleSignatures) + .def("GetPossibleSignaturesFromParents",&PythiaDISCrossSection::GetPossibleSignaturesFromParents) + .def("FinalStateProbability",&PythiaDISCrossSection::FinalStateProbability) + .def("GetMinimumQ2",&PythiaDISCrossSection::GetMinimumQ2) + .def("GetTargetMass",&PythiaDISCrossSection::GetTargetMass) + .def("GetInteractionType",&PythiaDISCrossSection::GetInteractionType) + .def_static("GeneratePythiaCharmSamples", + [](int interaction_type, int primary_pdg, int target_pdg, double target_mass, + std::string pdf_set, std::string pythia_data_path, double minimum_Q2, + std::vector energies, int n_events) { + std::vector sigma_mb, E, x, y; + PythiaDISCrossSection::GeneratePythiaCharmSamples( + interaction_type, primary_pdg, target_pdg, target_mass, + pdf_set, pythia_data_path, minimum_Q2, energies, n_events, + sigma_mb, E, x, y); + return pybind11::make_tuple(sigma_mb, E, x, y); + }, + arg("interaction_type"), arg("primary_pdg"), arg("target_pdg"), arg("target_mass"), + arg("pdf_set"), arg("pythia_data_path"), arg("minimum_Q2"), + arg("energies"), arg("n_events"), + "Run Pythia (init once per energy) and return (sigma_mb_per_E, E, x, y) raw samples " + "for building charm-DIS splines. See siren.interactions.pythia_charm_splines."); +} + +#else + +void register_PythiaDISCrossSection(pybind11::module_ & m) { + // Pythia8 is not available, so we do not register the PythiaDISCrossSection class. +} + +#endif diff --git a/projects/interactions/private/pybindings/QuarkDISFromSpline.h b/projects/interactions/private/pybindings/QuarkDISFromSpline.h new file mode 100644 index 000000000..f65962968 --- /dev/null +++ b/projects/interactions/private/pybindings/QuarkDISFromSpline.h @@ -0,0 +1,89 @@ +#include +#include +#include + +#include +#include +#include + +#include "../../public/SIREN/interactions/CrossSection.h" +#include "../../public/SIREN/interactions/QuarkDISFromSpline.h" +#include "../../../dataclasses/public/SIREN/dataclasses/Particle.h" +#include "../../../dataclasses/public/SIREN/dataclasses/InteractionRecord.h" +#include "../../../dataclasses/public/SIREN/dataclasses/InteractionSignature.h" +#include "../../../geometry/public/SIREN/geometry/Geometry.h" +#include "../../../utilities/public/SIREN/utilities/Random.h" + +void register_QuarkDISFromSpline(pybind11::module_ & m) { + using namespace pybind11; + using namespace siren::interactions; + + class_, CrossSection> quarkdisfromspline(m, "QuarkDISFromSpline"); + + quarkdisfromspline + + .def(init<>()) + .def(init, std::vector, int, double, double, std::set, std::set, std::string>(), + arg("differential_xs_data"), + arg("total_xs_data"), + arg("interaction"), + arg("target_mass"), + arg("minimum_Q2"), + arg("primary_types"), + arg("target_types"), + arg("units") = std::string("cm")) + .def(init, std::vector, int, double, double, std::vector, std::vector, std::string>(), + arg("differential_xs_data"), + arg("total_xs_data"), + arg("interaction"), + arg("target_mass"), + arg("minimum_Q2"), + arg("primary_types"), + arg("target_types"), + arg("units") = std::string("cm")) + .def(init, std::set, std::string>(), + arg("differential_xs_filename"), + arg("total_xs_filename"), + arg("interaction"), + arg("target_mass"), + arg("minimum_Q2"), + arg("primary_types"), + arg("target_types"), + arg("units") = std::string("cm")) + .def(init, std::set, std::string>(), + arg("differential_xs_filename"), + arg("total_xs_filename"), + arg("primary_types"), + arg("target_types"), + arg("units") = std::string("cm")) + .def(init, std::vector, std::string>(), + arg("differential_xs_filename"), + arg("total_xs_filename"), + arg("interaction"), + arg("target_mass"), + arg("minimum_Q2"), + arg("primary_types"), + arg("target_types"), + arg("units") = std::string("cm")) + .def(init, std::vector, std::string>(), + arg("differential_xs_filename"), + arg("total_xs_filename"), + arg("primary_types"), + arg("target_types"), + arg("units") = std::string("cm")) + .def(self == self) + .def("SetInteractionType",&QuarkDISFromSpline::SetInteractionType) + .def("TotalCrossSection",overload_cast(&QuarkDISFromSpline::TotalCrossSection, const_)) + .def("TotalCrossSection",overload_cast(&QuarkDISFromSpline::TotalCrossSection, const_)) + .def("DifferentialCrossSection",overload_cast(&QuarkDISFromSpline::DifferentialCrossSection, const_)) + .def("DifferentialCrossSection",overload_cast(&QuarkDISFromSpline::DifferentialCrossSection, const_)) + .def("InteractionThreshold",&QuarkDISFromSpline::InteractionThreshold) + .def("FragmentationFraction",&QuarkDISFromSpline::FragmentationFraction) + .def("GetPossibleTargets",&QuarkDISFromSpline::GetPossibleTargets) + .def("GetPossibleTargetsFromPrimary",&QuarkDISFromSpline::GetPossibleTargetsFromPrimary) + .def("GetPossiblePrimaries",&QuarkDISFromSpline::GetPossiblePrimaries) + .def("GetPossibleSignatures",&QuarkDISFromSpline::GetPossibleSignatures) + .def("GetPossibleSignaturesFromParents",&QuarkDISFromSpline::GetPossibleSignaturesFromParents) + .def("FinalStateProbability",&QuarkDISFromSpline::FinalStateProbability); +} + diff --git a/projects/interactions/private/pybindings/interactions.cxx b/projects/interactions/private/pybindings/interactions.cxx index 0a97735ca..703ab4871 100644 --- a/projects/interactions/private/pybindings/interactions.cxx +++ b/projects/interactions/private/pybindings/interactions.cxx @@ -16,14 +16,22 @@ #include "../../public/SIREN/interactions/HNLDISFromSpline.h" #include "../../public/SIREN/interactions/HNLDecay.h" #include "../../public/SIREN/interactions/InteractionCollection.h" +#include "../../public/SIREN/interactions/QuarkDISFromSpline.h" +#include "../../public/SIREN/interactions/CharmMesonDecay.h" +#include "../../public/SIREN/interactions/CharmMesonDecay3Body.h" +#include "../../public/SIREN/interactions/DMesonELoss.h" +#ifdef SIREN_HAS_PYTHIA8 +#include "../../public/SIREN/interactions/PythiaDISCrossSection.h" +#endif #include "./Interaction.h" #include "./CrossSection.h" #include "./DarkNewsCrossSection.h" #include "./DarkNewsDecay.h" -#include "./Decay.h" #include "./DISFromSpline.h" +#include "./QuarkDISFromSpline.h" +#include "./Decay.h" #include "./DummyCrossSection.h" //#include "./ElasticScattering.h" #include "./ElectroweakDecay.h" @@ -33,6 +41,10 @@ #include "./HNLDISFromSpline.h" #include "./HNLDecay.h" #include "./InteractionCollection.h" +#include "./CharmMesonDecay.h" +#include "./CharmMesonDecay3Body.h" +#include "./DMesonELoss.h" +#include "./PythiaDISCrossSection.h" #include "./MarleyCrossSection.h" #include @@ -61,5 +73,10 @@ PYBIND11_MODULE(interactions,m) { register_HNLDISFromSpline(m); register_HNLDecay(m); register_InteractionCollection(m); + register_QuarkDISFromSpline(m); + register_CharmMesonDecay(m); + register_CharmMesonDecay3Body(m); + register_DMesonELoss(m); + register_PythiaDISCrossSection(m); register_MarleyCrossSection(m); } diff --git a/projects/interactions/private/test/CharmDISClosure_TEST.cxx b/projects/interactions/private/test/CharmDISClosure_TEST.cxx new file mode 100644 index 000000000..404c1107b --- /dev/null +++ b/projects/interactions/private/test/CharmDISClosure_TEST.cxx @@ -0,0 +1,164 @@ +// Regression test for the charm-DIS interaction-depth closure invariant: the +// generation side (TotalCrossSectionAllFinalStates) and physical side (sum of +// per-signature TotalCrossSection, Weighter.tcc) must agree, and the inclusive +// sigma must be partitioned across the three D species by fragmentation fraction +// (else the sum triple-counts). Pythia-free: a mock reproduces PythiaDIS's two +// relevant behaviors and exercises the real base-class TotalCrossSectionAllFinalStates. +// Companion PythiaDISCharmClosure_TEST exercises the real class. + +#include +#include +#include +#include + +#include + +#include "SIREN/interactions/CrossSection.h" +#include "SIREN/dataclasses/Particle.h" +#include "SIREN/dataclasses/InteractionRecord.h" +#include "SIREN/dataclasses/InteractionSignature.h" + +using namespace siren::interactions; +using namespace siren::dataclasses; + +namespace { + +// Mock reproducing PythiaDISCrossSection's relevant semantics. +// apply_ff : multiply by FragmentationFraction per signature (as in QuarkDISFromSpline) +// override_afs : short-circuit TotalCrossSectionAllFinalStates to TotalCrossSection +// instead of the base per-signature sum +class MockCharmXS : public CrossSection { +public: + bool apply_ff; + bool override_afs; + MockCharmXS(bool ff, bool ovr) : apply_ff(ff), override_afs(ovr) {} + + // D0:D+/-:Ds = 0.60:0.23:0.15 each /0.98 to sum to 1.0 (unmodeled Lambda_c + // redistributed). Mirrors QuarkDISFromSpline::FragmentationFraction. + static double FragmentationFraction(ParticleType d) { + if(d==ParticleType::D0 || d==ParticleType::D0Bar) return 0.6 / 0.98; + if(d==ParticleType::DPlus || d==ParticleType::DMinus) return 0.23 / 0.98; + if(d==ParticleType::DsPlus || d==ParticleType::DsMinus) return 0.15 / 0.98; + return 0.0; + } + // Stand-in for the 1-D inclusive charm total spline (independent of meson type). + static double sigma_inclusive(double E) { return 1.0e-38 * std::log10(E); } + + std::vector sigs(ParticleType primary, ParticleType target) const { + InteractionSignature base; + base.primary_type = primary; + base.target_type = target; + base.secondary_types = { ParticleType::MuMinus, ParticleType::D0 }; + std::vector out; + for(ParticleType d : { ParticleType::D0, ParticleType::DPlus, ParticleType::DsPlus }) { + InteractionSignature s = base; + s.secondary_types[1] = d; + out.push_back(s); + } + return out; + } + + double TotalCrossSection(InteractionRecord const & r) const override { + double s = sigma_inclusive(r.primary_momentum[0]); + if(apply_ff) { + for(auto t : r.signature.secondary_types) + if(isD(t)) { s *= FragmentationFraction(t); break; } + } + return s; + } + double TotalCrossSectionAllFinalStates(InteractionRecord const & r) const override { + if(override_afs) return TotalCrossSection(r); + return CrossSection::TotalCrossSectionAllFinalStates(r); + } + std::vector GetPossibleSignaturesFromParents(ParticleType p, ParticleType t) const override { + return sigs(p, t); + } + std::vector GetPossibleSignatures() const override { return sigs(ParticleType::NuMu, ParticleType::PPlus); } + std::vector GetPossibleTargets() const override { return { ParticleType::PPlus }; } + std::vector GetPossibleTargetsFromPrimary(ParticleType) const override { return { ParticleType::PPlus }; } + std::vector GetPossiblePrimaries() const override { return { ParticleType::NuMu }; } + double DifferentialCrossSection(InteractionRecord const &) const override { return 0.0; } + double InteractionThreshold(InteractionRecord const &) const override { return 0.0; } + double FinalStateProbability(InteractionRecord const &) const override { return 0.0; } + std::vector DensityVariables() const override { return {}; } + void SampleFinalState(CrossSectionDistributionRecord &, std::shared_ptr) const override {} + bool equal(CrossSection const &) const override { return false; } +}; + +// Replicates the Weighter.tcc physical-side loop (InteractionProbability, +// NormalizedPositionProbability) and WeightingUtils CrossSectionProbability: +// sum TotalCrossSection over GetPossibleSignaturesFromParents. +double phys_path(MockCharmXS const & xs, ParticleType primary, ParticleType target, double E) { + InteractionRecord fake; + fake.signature.primary_type = primary; + fake.signature.target_type = target; + fake.primary_momentum[0] = E; + double total = 0.0; + for(auto const & sig : xs.GetPossibleSignaturesFromParents(primary, target)) { + fake.signature = sig; + fake.primary_momentum[0] = E; + total += xs.TotalCrossSection(fake); + } + return total; +} + +double gen_path(MockCharmXS const & xs, ParticleType primary, ParticleType target, double E) { + InteractionRecord rec; + rec.signature.primary_type = primary; + rec.signature.target_type = target; + rec.primary_momentum[0] = E; + return xs.TotalCrossSectionAllFinalStates(rec); +} + +} // namespace + +// f1751c6b bug: override makes the generation side report 1x while the physical +// side reports 3x -> closure broken by a factor of 3. +TEST(CharmDISClosure, OverrideBreaksClosureByFactorThree) { + MockCharmXS xs(/*ff=*/false, /*override=*/true); + for(double E : {10.0, 100.0, 1000.0}) { + double gen = gen_path(xs, ParticleType::NuMu, ParticleType::PPlus, E); + double phys = phys_path(xs, ParticleType::NuMu, ParticleType::PPlus, E); + EXPECT_NEAR(gen, MockCharmXS::sigma_inclusive(E), 1e-50); // 1x + EXPECT_NEAR(phys, 3.0 * MockCharmXS::sigma_inclusive(E), 1e-50); // 3x + EXPECT_NEAR(gen / phys, 1.0 / 3.0, 1e-9); + } +} + +// 4b7baf4a (override removed, no FF): sides agree (closure restored) but both +// equal 3x the inclusive sigma -> charm production overcounted. +TEST(CharmDISClosure, OverrideRemovedClosesButOvercountsThreeX) { + MockCharmXS xs(/*ff=*/false, /*override=*/false); + for(double E : {10.0, 100.0, 1000.0}) { + double gen = gen_path(xs, ParticleType::NuMu, ParticleType::PPlus, E); + double phys = phys_path(xs, ParticleType::NuMu, ParticleType::PPlus, E); + EXPECT_NEAR(gen, phys, 1e-50); // closure ok + EXPECT_NEAR(gen, 3.0 * MockCharmXS::sigma_inclusive(E), 1e-50); // but 3x high + } +} + +// With FF applied per signature (FFs renormalized to sum to 1.0), both sides +// agree AND equal the full inclusive sigma -- partitioned, not triple-counted. +TEST(CharmDISClosure, FragmentationFractionRestoresPhysicalNormalization) { + MockCharmXS xs(/*ff=*/true, /*override=*/false); + for(double E : {10.0, 100.0, 1000.0}) { + double gen = gen_path(xs, ParticleType::NuMu, ParticleType::PPlus, E); + double phys = phys_path(xs, ParticleType::NuMu, ParticleType::PPlus, E); + double s = MockCharmXS::sigma_inclusive(E); + EXPECT_NEAR(gen, phys, 1e-50); // closure ok + EXPECT_NEAR(gen, s, 1e-48); // and physically normalized (== inclusive sigma) + } +} + +// The three implemented fragmentation fractions sum to 1.0. +TEST(CharmDISClosure, FragmentationFractionsSumToOne) { + double sum = MockCharmXS::FragmentationFraction(ParticleType::D0) + + MockCharmXS::FragmentationFraction(ParticleType::DPlus) + + MockCharmXS::FragmentationFraction(ParticleType::DsPlus); + EXPECT_NEAR(sum, 1.0, 1e-12); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/projects/interactions/private/test/CharmDecayTestHelpers.h b/projects/interactions/private/test/CharmDecayTestHelpers.h new file mode 100644 index 000000000..d78b4f786 --- /dev/null +++ b/projects/interactions/private/test/CharmDecayTestHelpers.h @@ -0,0 +1,64 @@ +#pragma once +#ifndef SIREN_CharmDecayTestHelpers_H +#define SIREN_CharmDecayTestHelpers_H + +// Shared q^2 reconstruction + V-A angle-average numeric oracle for the +// CharmMesonDecay closure tests (included by both decay test files). + +#include + +#include +#include + +#include "SIREN/dataclasses/InteractionRecord.h" + +namespace siren { +namespace interactions { +namespace charm_decay_test { + +// q^2 = (p_D - p_K)^2 from a finalized record. +inline double reconstruct_q2(const siren::dataclasses::InteractionRecord & rec) { + double qE = rec.primary_momentum[0] - rec.secondary_momenta[0][0]; + double qpx = rec.primary_momentum[1] - rec.secondary_momenta[0][1]; + double qpy = rec.primary_momentum[2] - rec.secondary_momenta[0][2]; + double qpz = rec.primary_momentum[3] - rec.secondary_momenta[0][3]; + return qE * qE - qpx * qpx - qpy * qpy - qpz * qpz; +} + +// Independent numeric quadrature of the clamped V-A weight, evaluated with the +// same rk::P4 boosts SampleFinalState uses. Cross-checks the closed-form +// charm_decay::VAWeightAngleAverage used by the weighting code. +inline double numericVAWeightAngleAverage(double mD, double mK, double ml, double m23) { + double mnu = 0.0; + double p1Abs = 0.5 * std::sqrt((mD - mK - m23) * (mD + mK + m23) + * (mD + mK - m23) * (mD - mK + m23)) / mD; + double p23Abs = 0.5 * std::sqrt((m23 - ml - mnu) * (m23 + ml + mnu) + * (m23 + ml - mnu) * (m23 - ml + mnu)) / m23; + if (p1Abs <= 0.0 || p23Abs <= 0.0) return 0.0; + double wtMEmax = std::min(std::pow(mD, 4) / 16.0, + mD * (mD - mK - ml) * (mD - mK - mnu) * (mD - ml - mnu)); + rk::P4 p4m23(geom3::Vector3(0.0, 0.0, -p1Abs), m23); + rk::Boost boost = p4m23.labBoost(); + rk::P4 p4K(geom3::Vector3(0.0, 0.0, p1Abs), mK); + const int N = 20000; // even -> composite Simpson + double h = 2.0 / N, sum = 0.0; + for (int i = 0; i <= N; ++i) { + double c = -1.0 + i * h; + double s = std::sqrt(std::max(0.0, 1.0 - c * c)); + geom3::Vector3 dir(s, 0.0, c); + rk::P4 p4l = rk::P4(p23Abs * dir, ml).boost(boost); + rk::P4 p4nu = rk::P4(-p23Abs * dir, mnu).boost(boost); + double w = mD * p4l.e() * p4nu.dot(p4K); + if (w < 0.0) w = 0.0; + if (w > wtMEmax) w = wtMEmax; + double wgt = (i == 0 || i == N) ? 1.0 : ((i % 2) ? 4.0 : 2.0); + sum += wgt * w; + } + return 0.5 * (sum * h / 3.0); +} + +} // namespace charm_decay_test +} // namespace interactions +} // namespace siren + +#endif // SIREN_CharmDecayTestHelpers_H diff --git a/projects/interactions/private/test/CharmMesonDecay3Body_TEST.cxx b/projects/interactions/private/test/CharmMesonDecay3Body_TEST.cxx new file mode 100644 index 000000000..c74600fc6 --- /dev/null +++ b/projects/interactions/private/test/CharmMesonDecay3Body_TEST.cxx @@ -0,0 +1,358 @@ +/** + * Unit tests for CharmMesonDecay3Body -- Pythia-style 3-body phase-space decay + * D -> K + lepton + neutrino with V-A reweighting and K/K*(892) mass mixing. + * Covers 4-momentum conservation, daughter mass shells / m23 window, the K vs + * K* mixing fraction, decay-width & branching bookkeeping, and the + * SampleFinalState <-> FinalStateProbability q^2 closure. + */ +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include "SIREN/interactions/CharmMesonDecay3Body.h" +#include "SIREN/interactions/CharmDecayKinematics.h" +#include "SIREN/dataclasses/Particle.h" +#include "SIREN/dataclasses/InteractionRecord.h" +#include "SIREN/dataclasses/InteractionSignature.h" +#include "SIREN/utilities/Random.h" +#include "SIREN/utilities/Constants.h" + +#include "CharmDecayTestHelpers.h" + +using namespace siren::utilities; +using namespace siren::interactions; +using namespace siren::dataclasses; +using siren::interactions::charm_decay_test::reconstruct_q2; +using siren::interactions::charm_decay_test::numericVAWeightAngleAverage; + +namespace { + +// Build an unfinalized 3-body semileptonic record for a boosted D meson. +InteractionRecord make_semilep_record(const InteractionSignature & sig, + double mD, double mK, double ml, double E_D) { + double p_D = std::sqrt(E_D * E_D - mD * mD); + InteractionRecord rec; + rec.signature = sig; + rec.primary_mass = mD; + rec.primary_momentum = {E_D, 0, 0, p_D}; + rec.primary_helicity = 0; + rec.target_mass = 0; + rec.target_helicity = 0; + rec.secondary_momenta = {{0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}}; + rec.secondary_masses = {mK, ml, 0.0}; + rec.secondary_helicities = {0, 0, 0}; + return rec; +} + +} // namespace + +// --- Test 1: exact 4-momentum conservation ---------------------------------- + +TEST(CharmMesonDecay3Body, ThreeBodyEnergyMomentumConservation) { + CharmMesonDecay3Body decay(ParticleType::D0); + auto sigs = decay.GetPossibleSignaturesFromParent(ParticleType::D0); + ASSERT_GE(sigs.size(), 2u); + + double mD = Constants::D0Mass; + double E_D = 100.0; + auto rng = std::make_shared(); + + std::vector> cases = { // e and mu modes + {sigs[0], Constants::electronMass}, + {sigs[1], Constants::muonMass}, + }; + + for (auto & c : cases) { + const InteractionSignature & sig = c.first; + double ml = c.second; + for (int i = 0; i < 2000; ++i) { + InteractionRecord rec = make_semilep_record(sig, mD, Constants::KMinusMass, ml, E_D); + CrossSectionDistributionRecord cdr(rec); + decay.SampleFinalState(cdr, rng); + cdr.Finalize(rec); + ASSERT_EQ(rec.secondary_momenta.size(), 3u); + for (int comp = 0; comp < 4; ++comp) { + double s = rec.secondary_momenta[0][comp] + + rec.secondary_momenta[1][comp] + + rec.secondary_momenta[2][comp]; + EXPECT_NEAR(s, rec.primary_momentum[comp], 1e-5); + } + } + } +} + +// --- Test 2: daughter mass shells & m23 window ------------------------------ + +TEST(CharmMesonDecay3Body, DaughterMassShells) { + CharmMesonDecay3Body decay(ParticleType::D0); + auto sig = decay.GetPossibleSignaturesFromParent(ParticleType::D0)[0]; // D0 -> K- e+ nu + double mD = Constants::D0Mass; + double ml = Constants::electronMass; + double mK_base = Constants::KMinusMass; + double mKstar = Constants::KPrimePlusMass; // KStarMass() in the source + double E_D = 100.0; + auto rng = std::make_shared(); + + for (int i = 0; i < 5000; ++i) { + InteractionRecord rec = make_semilep_record(sig, mD, mK_base, ml, E_D); + CrossSectionDistributionRecord cdr(rec); + decay.SampleFinalState(cdr, rng); + cdr.Finalize(rec); + + const auto & pK = rec.secondary_momenta[0]; + const auto & pl = rec.secondary_momenta[1]; + const auto & pnu = rec.secondary_momenta[2]; + + auto mass = [](const std::array & p) { + double m2 = p[0]*p[0] - p[1]*p[1] - p[2]*p[2] - p[3]*p[3]; + return std::sqrt(std::max(0.0, m2)); + }; + + EXPECT_NEAR(mass(pl), ml, 1e-5); // lepton on shell + EXPECT_NEAR(mass(pnu), 0.0, 1e-5); // neutrino massless + + // hadron mass is one of the two mixture components. + double mK = mass(pK); + bool is_K = std::abs(mK - mK_base) < 1e-4; + bool is_Kstar = std::abs(mK - mKstar) < 1e-4; + EXPECT_TRUE(is_K || is_Kstar) << "hadron mass " << mK << " is neither K nor K*"; + + // m23 = (lepton + neutrino) invariant mass within [ml, mD - mK]. + std::array p23 = {pl[0]+pnu[0], pl[1]+pnu[1], pl[2]+pnu[2], pl[3]+pnu[3]}; + double m23 = mass(p23); + double m23max = mD - mK; + EXPECT_GE(m23, ml - 1e-4); + EXPECT_LE(m23, m23max + 1e-4); + } +} + +// --- Test 3: K / K*(892) mixing fraction ------------------------------------ + +TEST(CharmMesonDecay3Body, KStarMixingFraction) { + double mKstar = Constants::KPrimePlusMass; + auto rng = std::make_shared(); + int N = 20000; + + struct Case { + ParticleType primary; + double mD; + double mK_base; + double fracK; + }; + std::vector cases = { + {ParticleType::D0, Constants::D0Mass, Constants::KMinusMass, 3.41 / (3.41 + 2.17)}, + {ParticleType::DPlus, Constants::DPlusMass, Constants::K0Mass, 8.74 / (8.74 + 5.33)}, + }; + + for (auto & c : cases) { + CharmMesonDecay3Body decay(c.primary); + auto sig = decay.GetPossibleSignaturesFromParent(c.primary)[0]; + double ml = Constants::electronMass; + double E_D = 50.0; + long countK = 0; + for (int i = 0; i < N; ++i) { + InteractionRecord rec = make_semilep_record(sig, c.mD, c.mK_base, ml, E_D); + CrossSectionDistributionRecord cdr(rec); + decay.SampleFinalState(cdr, rng); + cdr.Finalize(rec); + double m2 = rec.secondary_momenta[0][0]*rec.secondary_momenta[0][0] + - rec.secondary_momenta[0][1]*rec.secondary_momenta[0][1] + - rec.secondary_momenta[0][2]*rec.secondary_momenta[0][2] + - rec.secondary_momenta[0][3]*rec.secondary_momenta[0][3]; + double mK = std::sqrt(std::max(0.0, m2)); + if (std::abs(mK - c.mK_base) < std::abs(mK - mKstar)) countK++; + } + double emp = (double)countK / N; + double sigma = std::sqrt(c.fracK * (1.0 - c.fracK) / N); + EXPECT_NEAR(emp, c.fracK, 5.0 * sigma); + } +} + +// --- Test 4: decay widths & branching bookkeeping --------------------------- + +TEST(CharmMesonDecay3Body, TotalDecayWidthAndBranchingSums) { + // D0: BR_semilep(e) = BR_semilep(mu) = 0.0558; hadronic remainder = 1 - 2*0.0558. + // D+: BR_semilep(e) = BR_semilep(mu) = 0.1407; hadronic remainder = 1 - 2*0.1407. + struct Case { ParticleType primary; double br_semilep; }; + std::vector cases = { + {ParticleType::D0, 0.0558}, + {ParticleType::DPlus, 0.1407}, + }; + + for (auto & c : cases) { + CharmMesonDecay3Body decay(c.primary); + auto sigs = decay.GetPossibleSignaturesFromParent(c.primary); + ASSERT_EQ(sigs.size(), 3u); // e-mode, mu-mode, hadronic + + double sum_width = 0.0; + for (auto & sig : sigs) { + InteractionRecord r; + r.signature = sig; + double w = 0.0; + EXPECT_NO_THROW(w = decay.TotalDecayWidthForFinalState(r)); + EXPECT_GT(w, 0.0); + sum_width += w; + } + + // The three branching ratios partition unity. + double br_sum = c.br_semilep + c.br_semilep + (1.0 - 2.0 * c.br_semilep); + EXPECT_NEAR(br_sum, 1.0, 1e-12); + + // TotalDecayWidth(primary) is the sum over GetPossibleSignaturesFromParent. + double total = decay.TotalDecayWidth(c.primary); + EXPECT_NEAR(total, sum_width, std::abs(total) * 1e-12 + 1e-30); + EXPECT_GT(total, 0.0); + } + + // Unsupported primary throws. + CharmMesonDecay3Body decay(ParticleType::D0); + InteractionRecord bad_primary; + bad_primary.signature.primary_type = ParticleType::PiPlus; + bad_primary.signature.target_type = ParticleType::Decay; + bad_primary.signature.secondary_types = {ParticleType::Hadrons}; + EXPECT_THROW(decay.TotalDecayWidthForFinalState(bad_primary), std::runtime_error); + + // Matched primary (D0) but an unimplemented set of secondaries throws. + InteractionRecord bad_secondaries; + bad_secondaries.signature.primary_type = ParticleType::D0; + bad_secondaries.signature.target_type = ParticleType::Decay; + bad_secondaries.signature.secondary_types = {ParticleType::PiPlus, ParticleType::PiMinus, ParticleType::Pi0}; + EXPECT_THROW(decay.TotalDecayWidthForFinalState(bad_secondaries), std::runtime_error); +} + +// --- Test 5: FinalStateProbability sanity + q^2 closure --------------------- + +TEST(CharmMesonDecay3Body, FinalStateProbabilityClosure) { + CharmMesonDecay3Body decay(ParticleType::D0); + auto sigs = decay.GetPossibleSignaturesFromParent(ParticleType::D0); + auto sig = sigs[0]; // D0 -> K- e+ nu + + double mD = Constants::D0Mass; + double ml = Constants::electronMass; + double mK = Constants::KMinusMass; + double mKstar = Constants::KPrimePlusMass; + double E_D = 100.0; + auto rng = std::make_shared(); + + // (a) The fully hadronic catch-all has FinalStateProbability == 1. + { + auto had_sig = sigs[2]; // D0 -> Hadrons + InteractionRecord rec; + rec.signature = had_sig; + rec.primary_mass = mD; + rec.primary_momentum = {E_D, 0, 0, std::sqrt(E_D * E_D - mD * mD)}; + rec.secondary_momenta = {{0, 0, 0, 0}}; + rec.secondary_masses = {0.0}; + EXPECT_NEAR(decay.FinalStateProbability(rec), 1.0, 1e-12); + } + + // (b) Histogram sampled q^2 per K/K* sub-population and compare each bin's + // empirical density to FinalStateProbability (which folds in the mixture + // weight) at a record rebuilt at that q^2 with the matching hadron mass. + const int NB = 16; + double q2lo = 0.0; + double q2hi = (mD - mK) * (mD - mK); // widest support (K mass) + double bw = (q2hi - q2lo) / NB; + std::vector countK(NB, 0), countKstar(NB, 0); + const int N = 40000; + + for (int i = 0; i < N; ++i) { + InteractionRecord rec = make_semilep_record(sig, mD, mK, ml, E_D); + CrossSectionDistributionRecord cdr(rec); + decay.SampleFinalState(cdr, rng); + cdr.Finalize(rec); + + double q2 = reconstruct_q2(rec); + double sampled_mK = rec.secondary_masses[0]; + double fsp = decay.FinalStateProbability(rec); + EXPECT_GE(fsp, 0.0); + + int b = (int)((q2 - q2lo) / bw); + if (b < 0) b = 0; + if (b >= NB) b = NB - 1; + if (std::abs(sampled_mK - mK) < std::abs(sampled_mK - mKstar)) countK[b]++; + else countKstar[b]++; + } + + // FinalStateProbability at a target q^2 in the D rest frame, hadron mass comp_mK. + auto fsp_at_q2 = [&](double comp_mK, double q2) -> double { + double EK = (mD * mD + comp_mK * comp_mK - q2) / (2 * mD); + double pk_sq = EK * EK - comp_mK * comp_mK; + if (pk_sq < 0) return 0.0; + double PK = std::sqrt(pk_sq); + InteractionRecord r; + r.signature = sig; + r.primary_mass = mD; + r.primary_momentum = {mD, 0, 0, 0}; // D at rest; q^2 frame-independent + r.secondary_momenta = {{EK, PK, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}}; + r.secondary_masses = {comp_mK, ml, 0.0}; + return decay.FinalStateProbability(r); + }; + + for (int b = 0; b < NB; ++b) { + double q2c = q2lo + (b + 0.5) * bw; + if (countK[b] > 40 && q2c < (mD - mK) * (mD - mK)) { + double emp = (double)countK[b] / (N * bw); + double sigma = std::sqrt((double)countK[b]) / (N * bw); + double pred = fsp_at_q2(mK, q2c); + EXPECT_NEAR(emp, pred, 4.0 * sigma + 0.03 * pred); + } + if (countKstar[b] > 40 && q2c < (mD - mKstar) * (mD - mKstar)) { + double emp = (double)countKstar[b] / (N * bw); + double sigma = std::sqrt((double)countKstar[b]) / (N * bw); + double pred = fsp_at_q2(mKstar, q2c); + EXPECT_NEAR(emp, pred, 4.0 * sigma + 0.03 * pred); + } + } +} + +// --- Analytic angle-average matches a numeric quadrature oracle ------------ +// charm_decay::VAWeightAngleAverage (used by the weighting code) must match the +// numeric quadrature numericVAWeightAngleAverage (CharmDecayTestHelpers.h). +TEST(CharmMesonDecay3Body, VAWeightAngleAverageMatchesNumericReference) { + struct Case { double mD; double mK; double ml; }; + std::vector cases = { + {Constants::D0Mass, Constants::KMinusMass, Constants::electronMass}, + {Constants::D0Mass, Constants::KMinusMass, Constants::muonMass}, + {Constants::D0Mass, Constants::KPrimePlusMass, Constants::electronMass}, + {Constants::DPlusMass, Constants::K0Mass, Constants::electronMass}, + {Constants::DPlusMass, Constants::KPrimePlusMass, Constants::muonMass}, + }; + for (auto & cs : cases) { + double m23Min = cs.ml; + double m23Max = cs.mD - cs.mK; + const int NG = 40; + for (int g = 1; g < NG; ++g) { + double m23 = m23Min + (m23Max - m23Min) * (double)g / NG; + double ana = charm_decay::VAWeightAngleAverage(cs.mD, cs.mK, cs.ml, m23); + double num = numericVAWeightAngleAverage(cs.mD, cs.mK, cs.ml, m23); + double tol = 1e-4 * std::abs(num) + 1e-12; + EXPECT_NEAR(ana, num, tol) + << "mD=" << cs.mD << " mK=" << cs.mK << " ml=" << cs.ml << " m23=" << m23; + } + } +} + +// Only D0 and D+ are implemented here; unsupported species (e.g. Ds) and +// empty-signature records must throw rather than mis-decay or index out of bounds. +TEST(CharmMesonDecay3Body, UnsupportedSpeciesAndEmptySignatureThrow) { + EXPECT_THROW({ CharmMesonDecay3Body d(ParticleType::DsPlus); }, std::runtime_error); + CharmMesonDecay3Body dp(ParticleType::DPlus); + EXPECT_THROW(dp.GetPossibleSignaturesFromParent(ParticleType::DsPlus), std::runtime_error); + CharmMesonDecay3Body d0(ParticleType::D0); + InteractionRecord rec; // default: empty signature + EXPECT_THROW(d0.FinalStateProbability(rec), std::runtime_error); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/projects/interactions/private/test/CharmMesonDecay_TEST.cxx b/projects/interactions/private/test/CharmMesonDecay_TEST.cxx new file mode 100644 index 000000000..b28aea26b --- /dev/null +++ b/projects/interactions/private/test/CharmMesonDecay_TEST.cxx @@ -0,0 +1,424 @@ +/** + * Unit tests for CharmMesonDecay: Interpolator1D inverse-CDF behavior, the + * SampleFinalState <-> FinalStateProbability q^2 closure, decay-length physics, + * and loud failure on unsupported signatures. + */ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include "SIREN/utilities/Interpolator.h" +#include "SIREN/utilities/Integration.h" +#include "SIREN/utilities/Constants.h" +#include "SIREN/utilities/Random.h" +#include "SIREN/interactions/CharmMesonDecay.h" +#include "SIREN/interactions/CharmDecayKinematics.h" +#include "SIREN/dataclasses/Particle.h" +#include "SIREN/dataclasses/InteractionRecord.h" +#include "SIREN/dataclasses/InteractionSignature.h" + +#include "CharmDecayTestHelpers.h" + +using namespace siren::utilities; +using namespace siren::interactions; +using namespace siren::dataclasses; +using siren::interactions::charm_decay_test::reconstruct_q2; +using siren::interactions::charm_decay_test::numericVAWeightAngleAverage; + +// --- Test 1: Interpolator1D with known linear inverse CDF F(x)=x/1.4 ------ + +TEST(Interpolator1D, LinearInverseCDF) { + TableData1D table; + int N = 102; + for (int i = 0; i < N; ++i) { + double u = (double)i / (N - 1); // CDF: 0 to 1 + double q2 = u * 1.4; // Q2: 0 to 1.4 + table.x.push_back(u); + table.f.push_back(q2); + } + + Interpolator1D interp(table); + + double tol = 0.01; + EXPECT_NEAR(interp(0.0), 0.0, tol); + EXPECT_NEAR(interp(0.25), 0.35, tol); + EXPECT_NEAR(interp(0.5), 0.7, tol); + EXPECT_NEAR(interp(0.75), 1.05, tol); + EXPECT_NEAR(interp(1.0), 1.4, tol); + + // Sample mean must be 0.7 (uniform on [0,1.4]). + double sum = 0; + int Nsamp = 10000; + SIREN_random rng; + for (int i = 0; i < Nsamp; ++i) { + double u = rng.Uniform(0, 1); + sum += interp(u); + } + double mean = sum / Nsamp; + EXPECT_NEAR(mean, 0.7, 0.05); +} + +// --- Test 2: Interpolator1D with D meson decay CDF ----------------------- + +TEST(Interpolator1D, DMesonDecayCDF) { + // Build an inverse-CDF table locally from a single-pole form factor for + // D0 -> K- e+ nu_e (self-contained Interpolator1D sanity check). + double mD = Constants::D0Mass; + double mK = Constants::KMinusMass; + double F0CKM = 0.719; + double alpha = 0.50; + double ms = 2.00697; + double GF = Constants::FermiConstant; + + auto dGamma = [&](double Q2) -> double { + double Q2tilde = Q2 / (ms * ms); + double ff2 = std::pow(F0CKM / ((1 - Q2tilde) * (1 - alpha * Q2tilde)), 2); + double EK = 0.5 * (Q2 - (mD * mD + mK * mK)) / mD; + double pk_sq = EK * EK - mK * mK; + if (pk_sq < 0) return 0.0; + double PK = std::sqrt(pk_sq); + return std::pow(GF, 2) / (24 * std::pow(M_PI, 3)) * ff2 * std::pow(PK, 3); + }; + + // Normalization and CDF-table construction mirror SIREN exactly: Romberg + // over [0, 1.4], then 100 trapezoid nodes from 0.01 to ~1.39. + std::function pdf_func = dGamma; + double norm = rombergIntegrate(pdf_func, 0.0, 1.4); + + auto normed_pdf = [&](double Q2) -> double { + return dGamma(Q2) / norm; + }; + + double Q2_min = 0.0; + double Q2_max = 1.4; + std::vector Q2spline; + for (int i = 0; i < 100; ++i) { + Q2spline.push_back(0.01 + i * (Q2_max - Q2_min) / 100); + } + + std::vector cdf_Q2_nodes; + std::vector cdf_vector; + std::vector pdf_vector; + + cdf_Q2_nodes.push_back(0); + cdf_vector.push_back(0); + pdf_vector.push_back(0); + + for (size_t i = 0; i < Q2spline.size(); ++i) { + double cur_Q2 = Q2spline[i]; + double cur_pdf = normed_pdf(cur_Q2); + double area; + if (i == 0) { + area = cur_Q2 * cur_pdf * 0.5; + } else { + area = 0.5 * (pdf_vector[i - 1] + cur_pdf) * (Q2spline[i] - Q2spline[i - 1]); + } + pdf_vector.push_back(cur_pdf); + cdf_Q2_nodes.push_back(cur_Q2); + cdf_vector.push_back(area + cdf_vector.back()); + } + + cdf_Q2_nodes.push_back(Q2_max); + cdf_vector.push_back(1.0); + pdf_vector.push_back(0); + + // Inverse CDF: x=CDF, f=Q2. + TableData1D inverse_cdf_data; + inverse_cdf_data.x = cdf_vector; + inverse_cdf_data.f = cdf_Q2_nodes; + + Interpolator1D inverse_cdf(inverse_cdf_data); + + // Sample and compute mean Q^2 from the interpolator and from a manual + // linear interpolation; the two must agree (this guards the interpolator). + SIREN_random rng; + int Nsamp = 100000; + double sum_q2 = 0; + double sum_q2_linear = 0; + for (int i = 0; i < Nsamp; ++i) { + double u = rng.Uniform(0, 1); + sum_q2 += inverse_cdf(u); + double q2_lin = 0; + for (size_t j = 0; j < cdf_vector.size() - 1; ++j) { + if (cdf_vector[j] <= u && u <= cdf_vector[j + 1]) { + double t = (cdf_vector[j + 1] - cdf_vector[j] > 0) ? + (u - cdf_vector[j]) / (cdf_vector[j + 1] - cdf_vector[j]) : 0; + q2_lin = cdf_Q2_nodes[j] + (cdf_Q2_nodes[j + 1] - cdf_Q2_nodes[j]) * t; + break; + } + } + sum_q2_linear += q2_lin; + } + double mean_interp = sum_q2 / Nsamp; + double mean_linear = sum_q2_linear / Nsamp; + + EXPECT_NEAR(mean_interp, mean_linear, 0.05); +} + +// --- Test 3: SampleFinalState q^2 closes with FinalStateProbability ------- + +namespace { +// Record at a given q^2 in the D rest frame with hadron mass mK (one mixture +// component), for evaluating FinalStateProbability. +InteractionRecord make_record_at_q2(const InteractionSignature & sig, + double mD, double mK, double ml, double q2) { + double EK = (mD * mD + mK * mK - q2) / (2 * mD); + double PK = std::sqrt(std::max(0.0, EK * EK - mK * mK)); + InteractionRecord rec; + rec.signature = sig; + rec.primary_mass = mD; + rec.primary_momentum = {mD, 0, 0, 0}; + rec.target_mass = 0; + rec.secondary_momenta = {{EK, PK, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}}; + rec.secondary_masses = {mK, ml, 0.0}; + return rec; +} +} // namespace + +TEST(CharmMesonDecay, SampledQ2Distribution) { + // D0 -> K- e+ nu_e with kinematic K/K*(892) mixing; guards SampleFinalState + // against FinalStateProbability via normalization, mean, and bin-by-bin q^2. + CharmMesonDecay decay(ParticleType::D0); + auto sigs = decay.GetPossibleSignaturesFromParent(ParticleType::D0); + auto sig = sigs[0]; + + double mD = Constants::D0Mass; + double ml = Constants::electronMass; + double mK = Constants::KMinusMass; + double mKstar = Constants::KPrimePlusMass; + + double E_D = 100.0; // boosted D meson (q^2 is frame-independent) + double p_D = std::sqrt(E_D * E_D - mD * mD); + + auto rng = std::make_shared(); + int Nsamp = 30000; + double sum_q2 = 0; + double sum_q2_sq = 0; + + // Histogram q^2 separately for the K and K* sub-populations. + const int NB = 16; + double q2lo = 0.0; + double q2hi = (mD - mK) * (mD - mK); // widest support (K mass) + double bw = (q2hi - q2lo) / NB; + std::vector countK(NB, 0), countKstar(NB, 0); + + for (int i = 0; i < Nsamp; ++i) { + InteractionRecord rec; + rec.signature = sig; + rec.primary_mass = mD; + rec.primary_momentum = {E_D, 0, 0, p_D}; + rec.primary_helicity = 0; + rec.target_mass = 0; + rec.target_helicity = 0; + rec.secondary_momenta = {{0,0,0,0}, {0,0,0,0}, {0,0,0,0}}; + rec.secondary_masses = {mK, ml, 0.0}; + rec.secondary_helicities = {0, 0, 0}; + + CrossSectionDistributionRecord cdr(rec); + decay.SampleFinalState(cdr, rng); + cdr.Finalize(rec); + + double q2 = reconstruct_q2(rec); + sum_q2 += q2; + sum_q2_sq += q2 * q2; + + double sampled_mK = rec.secondary_masses[0]; + int b = (int)((q2 - q2lo) / bw); + if (b < 0) b = 0; + if (b >= NB) b = NB - 1; + if (std::abs(sampled_mK - mK) < std::abs(sampled_mK - mKstar)) countK[b]++; + else countKstar[b]++; + } + + double mean_sampled = sum_q2 / Nsamp; + double var_sampled = sum_q2_sq / Nsamp - mean_sampled * mean_sampled; + double stderr_mean = std::sqrt(var_sampled / Nsamp); + + // (1) FinalStateProbability integrated over q^2, summed over the K/K* + // mixture, must normalize to 1. + auto fsp_integral = [&](double comp_mK) -> double { + std::function integrand = [&](double q2) -> double { + InteractionRecord r = make_record_at_q2(sig, mD, comp_mK, ml, q2); + return decay.FinalStateProbability(r); + }; + double a = ml * ml; + double b = (mD - comp_mK) * (mD - comp_mK); + return rombergIntegrate(integrand, a, b); + }; + double normK = fsp_integral(mK); + double normKstar = fsp_integral(mKstar); + double total_norm = normK + normKstar; + EXPECT_NEAR(total_norm, 1.0, 1e-2); + + // (2) Mean closure: quadrature of q^2 * FinalStateProbability matches the + // sampled mean within MC error. + auto fsp_q2_integral = [&](double comp_mK) -> double { + std::function integrand = [&](double q2) -> double { + InteractionRecord r = make_record_at_q2(sig, mD, comp_mK, ml, q2); + return q2 * decay.FinalStateProbability(r); + }; + double a = ml * ml; + double b = (mD - comp_mK) * (mD - comp_mK); + return rombergIntegrate(integrand, a, b); + }; + double mean_density = (fsp_q2_integral(mK) + fsp_q2_integral(mKstar)) / total_norm; + EXPECT_NEAR(mean_sampled, mean_density, 4.0 * stderr_mean); + + // (3) Bin-by-bin closure per sub-population: empirical density count/(N*bw) + // (N = total, folding in the sub-population fraction) matches + // FinalStateProbability at the bin center within Poisson error. + for (int b = 0; b < NB; ++b) { + double q2c = q2lo + (b + 0.5) * bw; + if (countK[b] > 30 && q2c < (mD - mK) * (mD - mK)) { + double emp = (double)countK[b] / (Nsamp * bw); + double sigma = std::sqrt((double)countK[b]) / (Nsamp * bw); + InteractionRecord r = make_record_at_q2(sig, mD, mK, ml, q2c); + double pred = decay.FinalStateProbability(r); + EXPECT_NEAR(emp, pred, 4.0 * sigma + 0.02 * pred); + } + if (countKstar[b] > 30 && q2c < (mD - mKstar) * (mD - mKstar)) { + double emp = (double)countKstar[b] / (Nsamp * bw); + double sigma = std::sqrt((double)countKstar[b]) / (Nsamp * bw); + InteractionRecord r = make_record_at_q2(sig, mD, mKstar, ml, q2c); + double pred = decay.FinalStateProbability(r); + EXPECT_NEAR(emp, pred, 4.0 * sigma + 0.02 * pred); + } + } +} + +// --- Test 4: TotalDecayWidthForFinalState fails loudly on bad signatures -- + +TEST(CharmMesonDecay, UnsupportedSignaturesThrow) { + CharmMesonDecay decay(ParticleType::D0); + + // Positive control. + auto sigs = decay.GetPossibleSignaturesFromParent(ParticleType::D0); + InteractionRecord good; + good.signature = sigs[0]; + double w = 0.0; + EXPECT_NO_THROW(w = decay.TotalDecayWidthForFinalState(good)); + EXPECT_GT(w, 0.0); + + // Unsupported primary type. + InteractionRecord bad_primary; + bad_primary.signature.primary_type = ParticleType::PiPlus; + bad_primary.signature.target_type = ParticleType::Decay; + bad_primary.signature.secondary_types = {ParticleType::Hadrons}; + EXPECT_THROW(decay.TotalDecayWidthForFinalState(bad_primary), std::runtime_error); + + // Matched primary (D0) but a signature with no implemented mode. + InteractionRecord bad_secondaries; + bad_secondaries.signature.primary_type = ParticleType::D0; + bad_secondaries.signature.target_type = ParticleType::Decay; + bad_secondaries.signature.secondary_types = {ParticleType::PiPlus, ParticleType::PiMinus}; + EXPECT_THROW(decay.TotalDecayWidthForFinalState(bad_secondaries), std::runtime_error); +} + +// --- Test 5: analytic angle-average matches a numeric quadrature oracle ----- +// charm_decay::VAWeightAngleAverage (used by the weighting code) must match the +// numeric quadrature numericVAWeightAngleAverage (CharmDecayTestHelpers.h). +TEST(CharmMesonDecay, VAWeightAngleAverageMatchesNumericReference) { + struct Case { double mD; double mK; double ml; }; + std::vector cases = { + {Constants::D0Mass, Constants::KMinusMass, Constants::electronMass}, + {Constants::D0Mass, Constants::KMinusMass, Constants::muonMass}, + {Constants::D0Mass, Constants::KPrimePlusMass, Constants::electronMass}, + {Constants::DPlusMass, Constants::K0Mass, Constants::electronMass}, + {Constants::DPlusMass, Constants::K0Mass, Constants::muonMass}, + {Constants::DPlusMass, Constants::KPrimePlusMass, Constants::muonMass}, + }; + for (auto & cs : cases) { + double m23Min = cs.ml; + double m23Max = cs.mD - cs.mK; + const int NG = 40; + for (int g = 1; g < NG; ++g) { + double m23 = m23Min + (m23Max - m23Min) * (double)g / NG; + double ana = charm_decay::VAWeightAngleAverage(cs.mD, cs.mK, cs.ml, m23); + double num = numericVAWeightAngleAverage(cs.mD, cs.mK, cs.ml, m23); + // Tolerance is quadrature-limited (Simpson degrades to O(h^2) at the + // clamp kinks); 1e-4 relative is far tighter than any real algebra bug. + double tol = 1e-4 * std::abs(num) + 1e-12; + EXPECT_NEAR(ana, num, tol) + << "mD=" << cs.mD << " mK=" << cs.mK << " ml=" << cs.ml << " m23=" << m23; + } + } +} + +// --- Test 6: lab decay length L = beta*gamma*c*tau (cascade separation) ------ +// Decay::TotalDecayLength = beta*gamma*(1/Gamma)*hbarc; since the modeled BRs +// sum to 1 the width recovers the PDG lifetime exactly. +namespace { +constexpr double tau_D0_s = 410.1e-15; // proper lifetimes hardcoded in +constexpr double tau_Dp_s = 1040.0e-15; // CharmMesonDecay (seconds). +constexpr double tau_Ds_s = 504.0e-15; +constexpr double c_m_per_s = 2.99792458e8; // speed of light [m/s] +// TotalDecayLength returns METERS (hbarc carries the cm->m conversion). + +InteractionRecord make_boosted_D(ParticleType d, double mD, double E_D) { + double p = std::sqrt(E_D * E_D - mD * mD); + InteractionRecord rec; + rec.signature.primary_type = d; + rec.signature.target_type = ParticleType::Decay; + rec.primary_mass = mD; + rec.primary_momentum = {E_D, 0.0, 0.0, p}; + return rec; +} +} // namespace + +TEST(CharmMesonDecay, LabDecayLengthIsBetaGammaCTau) { + struct Case { ParticleType d; double mD; double tau; }; + std::vector cases = { + {ParticleType::D0, Constants::D0Mass, tau_D0_s}, + {ParticleType::DPlus, Constants::DPlusMass, tau_Dp_s}, + {ParticleType::DsPlus, Constants::DsPlusMass, tau_Ds_s}, + }; + for (auto const & cs : cases) { + CharmMesonDecay decay(cs.d); + double prev_L = -1.0, prev_E = -1.0; + for (double E_D : {1.0e4, 1.0e5, 1.0e6}) { // 10 TeV, 100 TeV, 1 PeV + InteractionRecord rec = make_boosted_D(cs.d, cs.mD, E_D); + double L_m = decay.TotalDecayLength(rec); + double p = std::sqrt(E_D * E_D - cs.mD * cs.mD); + double betagamma = p / cs.mD; + double expected_m = betagamma * c_m_per_s * cs.tau; + // 0.5% absorbs the ~0.01% rounding of SIREN's hbar/hbarc constants. + EXPECT_NEAR(L_m, expected_m, 5e-3 * expected_m) + << "species=" << static_cast(cs.d) << " E_D=" << E_D; + // beta*gamma linear in E for E >> m: L(10x E) ~ 10x L. + if (prev_L > 0.0) + EXPECT_NEAR(L_m / prev_L, E_D / prev_E, 1e-3 * (E_D / prev_E)); + prev_L = L_m; prev_E = E_D; + } + } +} + +TEST(CharmMesonDecay, LabDecayLengthSpeciesOrdering) { + // At fixed boost energy the L ordering follows the lifetimes: D+ > Ds > D0. + double E_D = 1.0e5; // 100 TeV + CharmMesonDecay d0(ParticleType::D0), dp(ParticleType::DPlus), ds(ParticleType::DsPlus); + double L_D0 = d0.TotalDecayLength(make_boosted_D(ParticleType::D0, Constants::D0Mass, E_D)); + double L_Dp = dp.TotalDecayLength(make_boosted_D(ParticleType::DPlus, Constants::DPlusMass, E_D)); + double L_Ds = ds.TotalDecayLength(make_boosted_D(ParticleType::DsPlus, Constants::DsPlusMass, E_D)); + EXPECT_GT(L_Dp, L_Ds); + EXPECT_GT(L_Ds, L_D0); + // At 100 TeV a D0 travels a few meters -- squarely in the Taupede regime. + EXPECT_GT(L_D0, 1.0); // > 1 m + EXPECT_LT(L_D0, 50.0); // < 50 m +} + +TEST(CharmMesonDecay, FinalStateProbabilityThrowsOnEmptySignature) { + // Empty signature must throw, not index out of bounds (finalize() drops it). + CharmMesonDecay decay(ParticleType::D0); + InteractionRecord rec; // default: empty signature, no secondaries + EXPECT_THROW(decay.FinalStateProbability(rec), std::runtime_error); +} diff --git a/projects/interactions/private/test/DMesonELoss_TEST.cxx b/projects/interactions/private/test/DMesonELoss_TEST.cxx new file mode 100644 index 000000000..01c9b88c7 --- /dev/null +++ b/projects/interactions/private/test/DMesonELoss_TEST.cxx @@ -0,0 +1,243 @@ +// Unit tests for DMesonELoss: D -> {same D, Hadrons} energy-loss model. The +// inelasticity z is a truncated Gaussian that is also the advertised density, +// so Sample == Density (closure). See DMesonELoss.h for the physics. +#include +#include +#include +#include +#include + +#include + +#include "SIREN/interactions/DMesonELoss.h" +#include "SIREN/dataclasses/Particle.h" +#include "SIREN/dataclasses/InteractionRecord.h" +#include "SIREN/dataclasses/InteractionSignature.h" +#include "SIREN/utilities/Random.h" +#include "SIREN/utilities/Constants.h" +#include "SIREN/utilities/Errors.h" + +using namespace siren::utilities; +using namespace siren::interactions; +using namespace siren::dataclasses; + +namespace { + +// Build an unfinalized D0 -> {D0, Hadrons} record at a given lab energy along z. +InteractionRecord make_d0_record(const InteractionSignature & sig, double E_D) { + double mD = Constants::D0Mass; + double p_D = std::sqrt(E_D * E_D - mD * mD); + InteractionRecord rec; + rec.signature = sig; + rec.primary_mass = mD; + rec.primary_momentum = {E_D, 0, 0, p_D}; + rec.primary_helicity = 0; + rec.target_mass = Constants::protonMass; + rec.target_helicity = 0; + rec.secondary_momenta = {{0, 0, 0, 0}, {0, 0, 0, 0}}; + rec.secondary_masses = {mD, 0.0}; + rec.secondary_helicities = {0, 0}; + return rec; +} + +} // namespace + +// The D meson always loses energy: E_out in (mD, E_D). +TEST(DMesonELoss, EnergyMonotonicallyDecreases) { + DMesonELoss xs; + auto sigs = xs.GetPossibleSignaturesFromParents(ParticleType::D0, ParticleType::PPlus); + ASSERT_EQ(sigs.size(), 1u); + auto sig = sigs[0]; + EXPECT_EQ(sig.secondary_types[0], ParticleType::D0); + EXPECT_EQ(sig.secondary_types[1], ParticleType::Hadrons); + + double mD = Constants::D0Mass; + auto rng = std::make_shared(); + + for (double E_D : {10.0, 100.0, 1000.0}) { + for (int i = 0; i < 2000; ++i) { + InteractionRecord rec = make_d0_record(sig, E_D); + CrossSectionDistributionRecord cdr(rec); + xs.SampleFinalState(cdr, rng); + cdr.Finalize(rec); + + double E_out = rec.secondary_momenta[0][0]; + EXPECT_LT(E_out, E_D); // never gains energy (z >= z_min_ > 0) + EXPECT_GE(E_out, mD); // stays on/above mass shell (no z>z_max_ leakage) + EXPECT_GT(E_out, 0.0); + } + } +} + +// Outgoing D on-shell, energy conserved, both products collinear with +z. +TEST(DMesonELoss, FourMomentumAndMassInvariants) { + DMesonELoss xs; + auto sig = xs.GetPossibleSignaturesFromParents(ParticleType::D0, ParticleType::PPlus)[0]; + double mD = Constants::D0Mass; + double E_D = 100.0; + double p_D = std::sqrt(E_D * E_D - mD * mD); + auto rng = std::make_shared(); + + for (int i = 0; i < 3000; ++i) { + InteractionRecord rec = make_d0_record(sig, E_D); + CrossSectionDistributionRecord cdr(rec); + xs.SampleFinalState(cdr, rng); + cdr.Finalize(rec); + + const auto & pdm = rec.secondary_momenta[0]; // outgoing D + const auto & ph = rec.secondary_momenta[1]; // hadron + + // outgoing D on its mass shell. + double m2 = pdm[0]*pdm[0] - pdm[1]*pdm[1] - pdm[2]*pdm[2] - pdm[3]*pdm[3]; + EXPECT_NEAR(std::sqrt(std::max(0.0, m2)), mD, 1e-6); + + // energy conserved; hadron carries the loss and is ~massless. + EXPECT_NEAR(pdm[0] + ph[0], E_D, 1e-6); + EXPECT_GT(ph[0], 0.0); + double mh2 = ph[0]*ph[0] - ph[1]*ph[1] - ph[2]*ph[2] - ph[3]*ph[3]; + EXPECT_NEAR(mh2, 0.0, 1e-6); + + // Both products collinear with +z. Momentum is NOT conserved by design + // (massless hadron), so assert direction, not p-balance. + EXPECT_NEAR(pdm[1], 0.0, 1e-9); + EXPECT_NEAR(pdm[2], 0.0, 1e-9); + EXPECT_GT(pdm[3], 0.0); + EXPECT_NEAR(ph[1], 0.0, 1e-9); + EXPECT_NEAR(ph[2], 0.0, 1e-9); + EXPECT_GT(ph[3], 0.0); + EXPECT_GT(p_D, 0.0); // incoming +z direction + } +} + +// TotalCrossSection positive, increasing in E, and throws on bad primary. +TEST(DMesonELoss, TotalCrossSectionPositiveAndThrows) { + DMesonELoss xs; + // Positive and increasing over the (>1 PeV) validity range. + double last = -1.0; + for (double E : {1e6, 1e7, 1e8, 1e9}) { + double s = xs.TotalCrossSection(ParticleType::D0, E); + EXPECT_GT(s, 0.0); + if (last >= 0.0) EXPECT_GT(s, last); + last = s; + } + EXPECT_THROW(xs.TotalCrossSection(ParticleType::PiPlus, 1e7), std::runtime_error); +} + +// Sub-threshold primary fails recoverably with InjectionFailure. +TEST(DMesonELoss, SubThresholdThrows) { + DMesonELoss xs; + auto sig = xs.GetPossibleSignaturesFromParents(ParticleType::D0, ParticleType::PPlus)[0]; + auto rng = std::make_shared(); + double mD = Constants::D0Mass; + + // Energy exactly at the D mass (D at rest): no valid final state. + { + InteractionRecord rec; + rec.signature = sig; + rec.primary_mass = mD; + rec.primary_momentum = {mD, 0, 0, 0}; + rec.secondary_momenta = {{0, 0, 0, 0}, {0, 0, 0, 0}}; + rec.secondary_masses = {mD, 0.0}; + rec.secondary_helicities = {0, 0}; + CrossSectionDistributionRecord cdr(rec); + EXPECT_THROW(xs.SampleFinalState(cdr, rng), siren::utilities::InjectionFailure); + } + // Energy below the D mass. + { + double E_D = 0.5 * mD; + InteractionRecord rec; + rec.signature = sig; + rec.primary_mass = mD; + rec.primary_momentum = {E_D, 0, 0, 0}; + rec.secondary_momenta = {{0, 0, 0, 0}, {0, 0, 0, 0}}; + rec.secondary_masses = {mD, 0.0}; + rec.secondary_helicities = {0, 0}; + CrossSectionDistributionRecord cdr(rec); + EXPECT_THROW(xs.SampleFinalState(cdr, rng), siren::utilities::InjectionFailure); + } +} + +// Empirical sampled-z distribution matches FinalStateProbability bin-by-bin. +TEST(DMesonELoss, ZDensityClosureAcrossEnergies) { + DMesonELoss xs; + auto sig = xs.GetPossibleSignaturesFromParents(ParticleType::D0, ParticleType::PPlus)[0]; + auto rng = std::make_shared(); + const double mD = Constants::D0Mass; + const double zlo = 0.001; // == z_min_ + + // At low E the kinematic cut z <= 1 - mD/E truncates the Gaussian below its + // mean (z0=0.56) -- where a fixed-interval density would mis-normalize and + // break closure. The density applies the same E-dependent cut, so Sample == + // Density at every energy. Span low to high E to exercise that. + for (double E_D : {5.0, 10.0, 50.0, 200.0, 1000.0, 3000.0}) { + const double z_kin = 1.0 - mD / E_D; // kinematic upper limit + const double zhi = std::min(1.0, z_kin); // == min(z_max_, z_kin) + ASSERT_GT(zhi, zlo) << "E_D=" << E_D; + const double p_D = std::sqrt(E_D * E_D - mD * mD); + + const int NB = 20; + const double bw = (zhi - zlo) / NB; + std::vector counts(NB, 0); + const int N = 200000; + for (int i = 0; i < N; ++i) { + InteractionRecord rec = make_d0_record(sig, E_D); + CrossSectionDistributionRecord cdr(rec); + xs.SampleFinalState(cdr, rng); + cdr.Finalize(rec); + double z = 1.0 - rec.secondary_momenta[0][0] / E_D; + // sampler must respect the support the density advertises. + EXPECT_GE(z, zlo - 1e-9) << "E_D=" << E_D; + EXPECT_LE(z, zhi + 1e-9) << "E_D=" << E_D; + int b = (int)((z - zlo) / bw); + if (b < 0) b = 0; + if (b >= NB) b = NB - 1; + counts[b]++; + } + + auto fsp_at_z = [&](double z) -> double { + double E_out = E_D * (1.0 - z); + double p_out = std::sqrt(std::max(0.0, E_out * E_out - mD * mD)); + InteractionRecord r; + r.signature = sig; + r.primary_mass = mD; + r.primary_momentum = {E_D, 0, 0, p_D}; + r.target_mass = Constants::protonMass; + r.secondary_momenta = {{E_out, 0, 0, p_out}, {E_D - E_out, 0, 0, E_D - E_out}}; + r.secondary_masses = {mD, 0.0}; + return xs.FinalStateProbability(r); + }; + + // bin-by-bin closure within ~4 Poisson sigma. + for (int b = 0; b < NB; ++b) { + double zc = zlo + (b + 0.5) * bw; + double pred = fsp_at_z(zc); + EXPECT_GE(pred, 0.0); + if (counts[b] < 50) continue; + double emp = (double)counts[b] / (N * bw); + double sg = std::sqrt((double)counts[b]) / (N * bw); + EXPECT_NEAR(emp, pred, 4.0 * sg + 0.02 * pred) << "E_D=" << E_D << " z=" << zc; + } + + // density integrates to 1 over the realized support [zlo, zhi]. + int M = 4000; + double integral = 0.0, dz = (zhi - zlo) / M; + for (int i = 0; i <= M; ++i) { + double z = zlo + i * dz; + double w = (i == 0 || i == M) ? 0.5 : 1.0; + integral += w * fsp_at_z(z) * dz; + } + EXPECT_NEAR(integral, 1.0, 2e-2) << "E_D=" << E_D; + + // density vanishes ABOVE the kinematic cut (the fixed-interval bug left + // nonzero density where the sampler produces nothing). + double z_above = zhi + 0.4 * (1.0 - zhi); + if (z_above < 1.0 - 1e-9) { + EXPECT_EQ(fsp_at_z(z_above), 0.0) << "E_D=" << E_D << " z_above=" << z_above; + } + } +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/projects/interactions/private/test/InteractionSerialization_TEST.cxx b/projects/interactions/private/test/InteractionSerialization_TEST.cxx new file mode 100644 index 000000000..eda822ee1 --- /dev/null +++ b/projects/interactions/private/test/InteractionSerialization_TEST.cxx @@ -0,0 +1,110 @@ +// Serialization round-trip tests for non-default-constructible interaction +// classes relying on load_and_construct. cereal only recognizes a STATIC +// load_and_construct; a non-static one is silently ignored, leaving the body +// unread and corrupting the rest of the archive. A trailing sentinel after each +// object reads back correctly only if the body was fully consumed, so it detects +// a load that skips the body. (DarkNewsDecay excluded: abstract/python-backed.) + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include "SIREN/interactions/Decay.h" +#include "SIREN/interactions/CrossSection.h" +#include "SIREN/interactions/HNLDecay.h" +#include "SIREN/interactions/ElectroweakDecay.h" +#include "SIREN/interactions/HNLDipoleDecay.h" +#include "SIREN/interactions/ElasticScattering.h" +#include "SIREN/interactions/HNLDipoleFromTable.h" +#include "SIREN/dataclasses/Particle.h" + +using namespace siren::interactions; +using siren::dataclasses::ParticleType; + +namespace { +// Round-trip a polymorphic base pointer through a binary archive followed by a +// sentinel int (which reads back correctly only if the body was fully consumed). +template +std::shared_ptr roundtrip_with_sentinel(std::shared_ptr orig, bool & sentinel_ok) { + const int kSentinel = 0x5A5A5A; + std::stringstream ss; + { + cereal::BinaryOutputArchive oarchive(ss); + oarchive(orig); + int s = kSentinel; + oarchive(s); + } + std::shared_ptr loaded; + int s = 0; + { + cereal::BinaryInputArchive iarchive(ss); + iarchive(loaded); + iarchive(s); + } + sentinel_ok = (s == kSentinel); + return loaded; +} +} // namespace + +TEST(InteractionSerialization, HNLDecayRoundTrip) { + std::shared_ptr orig = std::make_shared( + 0.1, std::vector{0.0, 0.0, 1e-3}, HNLDecay::ChiralNature::Majorana); + bool ok = false; + std::shared_ptr loaded = roundtrip_with_sentinel(orig, ok); + ASSERT_NE(loaded, nullptr); + EXPECT_NE(std::dynamic_pointer_cast(loaded), nullptr); + EXPECT_TRUE(orig->equal(*loaded)); + EXPECT_TRUE(ok) << "HNLDecay body not consumed on load"; +} + +TEST(InteractionSerialization, ElectroweakDecayRoundTrip) { + std::shared_ptr orig = std::make_shared( + std::set{ParticleType::TauMinus}); + bool ok = false; + std::shared_ptr loaded = roundtrip_with_sentinel(orig, ok); + ASSERT_NE(loaded, nullptr); + EXPECT_NE(std::dynamic_pointer_cast(loaded), nullptr); + EXPECT_TRUE(orig->equal(*loaded)); + EXPECT_TRUE(ok) << "ElectroweakDecay body not consumed on load"; +} + +TEST(InteractionSerialization, HNLDipoleDecayRoundTrip) { + std::shared_ptr orig = std::make_shared( + 0.1, std::vector{0.0, 1e-6, 0.0}, HNLDipoleDecay::ChiralNature::Majorana); + bool ok = false; + std::shared_ptr loaded = roundtrip_with_sentinel(orig, ok); + ASSERT_NE(loaded, nullptr); + EXPECT_NE(std::dynamic_pointer_cast(loaded), nullptr); + EXPECT_TRUE(orig->equal(*loaded)); + EXPECT_TRUE(ok) << "HNLDipoleDecay body not consumed on load"; +} + +TEST(InteractionSerialization, ElasticScatteringRoundTrip) { + std::shared_ptr orig = std::make_shared( + std::set{ParticleType::NuMu}); + bool ok = false; + std::shared_ptr loaded = roundtrip_with_sentinel(orig, ok); + ASSERT_NE(loaded, nullptr); + EXPECT_NE(std::dynamic_pointer_cast(loaded), nullptr); + EXPECT_TRUE(orig->equal(*loaded)); + EXPECT_TRUE(ok) << "ElasticScattering body not consumed on load"; +} + +TEST(InteractionSerialization, HNLDipoleFromTableRoundTrip) { + std::shared_ptr orig = std::make_shared( + 0.1, 1e-6, HNLDipoleFromTable::HelicityChannel::Conserving); + bool ok = false; + std::shared_ptr loaded = roundtrip_with_sentinel(orig, ok); + ASSERT_NE(loaded, nullptr); + EXPECT_NE(std::dynamic_pointer_cast(loaded), nullptr); + EXPECT_TRUE(orig->equal(*loaded)); + EXPECT_TRUE(ok) << "HNLDipoleFromTable body not consumed on load"; +} diff --git a/projects/interactions/private/test/PythiaDISCharmClosure_TEST.cxx b/projects/interactions/private/test/PythiaDISCharmClosure_TEST.cxx new file mode 100644 index 000000000..4f3d744dc --- /dev/null +++ b/projects/interactions/private/test/PythiaDISCharmClosure_TEST.cxx @@ -0,0 +1,238 @@ +// Regression tests for the PythiaDISCrossSection charm-DIS interaction-depth +// closure bug. Guards that per-signature TotalCrossSection is partitioned by +// fragmentation fraction (sum == inclusive sigma, not 3x) and that the +// generation side (TotalCrossSectionAllFinalStates) equals the physical sum. +// See PythiaDISCrossSection.h. Requires SIREN_WITH_PYTHIA8 plus spline files in +// env vars SIREN_PYTHIA_TEST_DSDXDY / SIREN_PYTHIA_TEST_SIGMA; SKIPs if unset. + +#include +#include +#include +#include +#include + +#include + +#include "SIREN/interactions/CrossSection.h" +#include "SIREN/dataclasses/Particle.h" +#include "SIREN/dataclasses/InteractionRecord.h" +#include "SIREN/dataclasses/InteractionSignature.h" + +#ifdef SIREN_HAS_PYTHIA8 +#include "SIREN/interactions/PythiaDISCrossSection.h" +#endif + +using namespace siren::interactions; +using namespace siren::dataclasses; + +namespace { +double expected_ff(ParticleType d) { + // Raw fractions / 0.98 to fold in the unmodeled Lambda_c and renormalize to + // sum to 1.0. Mirrors PythiaDISCrossSection::FragmentationFraction. + if(d==ParticleType::D0 || d==ParticleType::D0Bar) return 0.6 / 0.98; + if(d==ParticleType::DPlus || d==ParticleType::DMinus) return 0.23 / 0.98; + if(d==ParticleType::DsPlus || d==ParticleType::DsMinus) return 0.15 / 0.98; + return 0.0; +} +} // namespace + +#ifdef SIREN_HAS_PYTHIA8 +TEST(PythiaDISCharmClosure, FragmentationPartitionAndClosure) { + const char* dd = std::getenv("SIREN_PYTHIA_TEST_DSDXDY"); + const char* tt = std::getenv("SIREN_PYTHIA_TEST_SIGMA"); + if(!dd || !tt) { + GTEST_SKIP() << "Set SIREN_PYTHIA_TEST_DSDXDY and SIREN_PYTHIA_TEST_SIGMA to run."; + } + + std::vector primaries = { ParticleType::NuMu }; + std::vector targets = { ParticleType::PPlus }; + PythiaDISCrossSection xs( + std::string(dd), std::string(tt), + /*interaction_type=*/1, /*target_mass=*/0.9382720813, + /*minimum_Q2=*/1.0, primaries, targets, + /*pythia_data_path=*/"", /*pdf_set=*/"LHAPDF6:CT18NLO", /*units=*/"cm"); + + const double ff_sum = (0.6 + 0.23 + 0.15) / 0.98; // renormalized -> 1.0 + + for(double E : {30.0, 100.0, 300.0}) { + double sigma_incl = xs.TotalCrossSection(ParticleType::NuMu, E); + ASSERT_GT(sigma_incl, 0.0); + + std::vector sigs = + xs.GetPossibleSignaturesFromParents(ParticleType::NuMu, ParticleType::PPlus); + ASSERT_EQ(sigs.size(), 3u); + + double sum = 0.0; + std::vector per_sig; + for(auto const & sig : sigs) { + InteractionRecord rec; + rec.signature = sig; + rec.primary_momentum[0] = E; + double v = xs.TotalCrossSection(rec); + sum += v; + per_sig.push_back(v); + + ParticleType d = ParticleType::unknown; + for(auto t : sig.secondary_types) if(isD(t)) { d = t; break; } + ASSERT_NE(d, ParticleType::unknown); + EXPECT_NEAR(v, sigma_incl * expected_ff(d), sigma_incl * 1e-9) + << "signature D-type cross section is not fragmentation-partitioned at E=" << E; + } + + // The three values must differ (0.6 vs 0.23 vs 0.15), not all == sigma_incl. + EXPECT_GT(std::abs(per_sig[0] - per_sig[1]), sigma_incl * 1e-6); + + // Sum is the partitioned inclusive sigma, not 3x. + EXPECT_NEAR(sum, sigma_incl * ff_sum, sigma_incl * 1e-9); + EXPECT_LT(sum, sigma_incl * 1.5); // would be 3x without the fix + + // Generation side == physical side (sum). + InteractionRecord rec; + rec.signature.primary_type = ParticleType::NuMu; + rec.signature.target_type = ParticleType::PPlus; + rec.primary_momentum[0] = E; + double gen = xs.TotalCrossSectionAllFinalStates(rec); + EXPECT_NEAR(gen, sum, sigma_incl * 1e-9) << "generation/physical interaction depth mismatch"; + } +} + +// With only a total spline (empty differential filename), FinalStateProbability +// returns a constant 1.0: the intractable Pythia final-state density cancels in +// the unbiased weight, so only the total cross section matters. +TEST(PythiaDISCharmClosure, ConstantFinalStateProbabilityWithoutDifferential) { + const char* tt = std::getenv("SIREN_PYTHIA_TEST_SIGMA"); + if(!tt) { + GTEST_SKIP() << "Set SIREN_PYTHIA_TEST_SIGMA to run."; + } + std::vector primaries = { ParticleType::NuMu }; + std::vector targets = { ParticleType::PPlus }; + PythiaDISCrossSection xs( + /*differential=*/std::string(""), std::string(tt), + /*interaction_type=*/1, /*target_mass=*/0.9382720813, + /*minimum_Q2=*/1.0, primaries, targets, + /*pythia_data_path=*/"", /*pdf_set=*/"LHAPDF6:CT18NLO", /*units=*/"cm"); + + EXPECT_GT(xs.TotalCrossSection(ParticleType::NuMu, 100.0), 0.0); + + // FSP is exactly 1.0: the no-differential branch returns before kinematics. + auto sig = xs.GetPossibleSignaturesFromParents(ParticleType::NuMu, ParticleType::PPlus)[0]; + InteractionRecord rec; + rec.signature = sig; + rec.primary_momentum = {100.0, 0.0, 0.0, 100.0}; + rec.target_mass = 0.9382720813; + rec.secondary_momenta = {{40.0, 1.0, 0.0, 39.0}, {0.0, 0.0, 0.0, 0.0}, {1.86, 0.0, 0.0, 1.0}}; + rec.secondary_masses = {0.105, 0.0, 1.86}; + EXPECT_EQ(xs.FinalStateProbability(rec), 1.0); +} + +// Charm is forced only in CC; NC charm must be rejected at construction (not +// later inside SampleFinalState) with an actionable error. +TEST(PythiaDISCharmClosure, NeutralCurrentRejectedAtConstruction) { + const char* tt = std::getenv("SIREN_PYTHIA_TEST_SIGMA"); + if(!tt) GTEST_SKIP() << "Set SIREN_PYTHIA_TEST_SIGMA to run."; + std::vector primaries = { ParticleType::NuMu }; + std::vector targets = { ParticleType::PPlus }; + EXPECT_THROW({ + PythiaDISCrossSection xs(std::string(""), std::string(tt), + /*interaction_type=*/2, 0.9382720813, 1.0, primaries, targets, + "", "LHAPDF6:CT18NLO", "cm"); + }, std::runtime_error); + // Positive control: CC still constructs cleanly. + EXPECT_NO_THROW({ + PythiaDISCrossSection xs(std::string(""), std::string(tt), + /*interaction_type=*/1, 0.9382720813, 1.0, primaries, targets, + "", "LHAPDF6:CT18NLO", "cm"); + }); +} + +// Species tripwire: pin the renormalized fractions, the raw sum (0.98), and +// Lambda_c -> 0 so modeling Lambda_c forces a test update rather than silently +// shifting the species mix. +TEST(PythiaDISCharmClosure, FragmentationFractionSpeciesTripwire) { + const char* tt = std::getenv("SIREN_PYTHIA_TEST_SIGMA"); + if(!tt) GTEST_SKIP() << "Set SIREN_PYTHIA_TEST_SIGMA to run."; + std::vector primaries = { ParticleType::NuMu }; + std::vector targets = { ParticleType::PPlus }; + PythiaDISCrossSection xs(std::string(""), std::string(tt), + 1, 0.9382720813, 1.0, primaries, targets, "", "LHAPDF6:CT18NLO", "cm"); + + EXPECT_NEAR(xs.FragmentationFraction(ParticleType::D0), 0.6 / 0.98, 1e-12); + EXPECT_NEAR(xs.FragmentationFraction(ParticleType::DPlus), 0.23 / 0.98, 1e-12); + EXPECT_NEAR(xs.FragmentationFraction(ParticleType::DsPlus), 0.15 / 0.98, 1e-12); + // Renormalized fractions recover the full inclusive sigma. + double sum = xs.FragmentationFraction(ParticleType::D0) + + xs.FragmentationFraction(ParticleType::DPlus) + + xs.FragmentationFraction(ParticleType::DsPlus); + EXPECT_NEAR(sum, 1.0, 1e-12); + // Raw fractions sum to 0.98; the 0.02 gap is the folded unmodeled baryon. + EXPECT_NEAR(0.6 + 0.23 + 0.15, 0.98, 1e-12); + // Lambda_c (PDG 4122) intentionally unmodeled -> FF 0. Modeling it must update this. + EXPECT_EQ(xs.FragmentationFraction(static_cast(4122)), 0.0); +} + +// Closure + partition must hold across the analysis band (TeV-PeV), not only +// <=300 GeV. Uses a wide total spline (100 GeV - 1 PeV) via SIREN_PYTHIA_WIDE_SIGMA. +TEST(PythiaDISCharmClosure, FragmentationClosureAtAnalysisEnergies) { + const char* tt = std::getenv("SIREN_PYTHIA_WIDE_SIGMA"); + if(!tt) GTEST_SKIP() << "Set SIREN_PYTHIA_WIDE_SIGMA (wide total spline, 100 GeV-1 PeV) to run."; + std::vector primaries = { ParticleType::NuMu }; + std::vector targets = { ParticleType::PPlus }; + PythiaDISCrossSection xs(std::string(""), std::string(tt), + 1, 0.9382720813, 1.0, primaries, targets, "", "LHAPDF6:CT18NLO", "cm"); + const double ff_sum = (0.6 + 0.23 + 0.15) / 0.98; + + for(double E : {1.0e4, 1.0e5, 1.0e6}) { // 10 TeV, 100 TeV, 1 PeV + double sigma_incl = 0.0; + ASSERT_NO_THROW(sigma_incl = xs.TotalCrossSection(ParticleType::NuMu, E)) + << "total spline does not cover E=" << E << " GeV"; + ASSERT_GT(sigma_incl, 0.0); + + auto sigs = xs.GetPossibleSignaturesFromParents(ParticleType::NuMu, ParticleType::PPlus); + ASSERT_EQ(sigs.size(), 3u); + double sum = 0.0; + for(auto const & sig : sigs) { + InteractionRecord rec; + rec.signature = sig; + rec.primary_momentum[0] = E; + double v = xs.TotalCrossSection(rec); + sum += v; + ParticleType d = ParticleType::unknown; + for(auto t : sig.secondary_types) if(isD(t)) { d = t; break; } + ASSERT_NE(d, ParticleType::unknown); + EXPECT_NEAR(v, sigma_incl * expected_ff(d), sigma_incl * 1e-9) << "E=" << E; + } + EXPECT_NEAR(sum, sigma_incl * ff_sum, sigma_incl * 1e-9) << "E=" << E; + + InteractionRecord rec; + rec.signature.primary_type = ParticleType::NuMu; + rec.signature.target_type = ParticleType::PPlus; + rec.primary_momentum[0] = E; + double gen = xs.TotalCrossSectionAllFinalStates(rec); + EXPECT_NEAR(gen, sum, sigma_incl * 1e-9) << "generation/physical mismatch at E=" << E; + } +} + +// Out-of-range differential evaluation must RAISE, not return 0 (a silent zero +// on a sampled event biases its weight). +TEST(PythiaDISCharmClosure, DifferentialOutOfRangeRaises) { + const char* dd = std::getenv("SIREN_PYTHIA_TEST_DSDXDY"); + const char* tt = std::getenv("SIREN_PYTHIA_TEST_SIGMA"); + if(!dd || !tt) GTEST_SKIP() << "Set SIREN_PYTHIA_TEST_DSDXDY and SIREN_PYTHIA_TEST_SIGMA to run."; + std::vector primaries = { ParticleType::NuMu }; + std::vector targets = { ParticleType::PPlus }; + PythiaDISCrossSection xs(std::string(dd), std::string(tt), + 1, 0.9382720813, 1.0, primaries, targets, "", "LHAPDF6:CT18NLO", "cm"); + + // In range -> finite positive density (Q2 computed from x,y). + EXPECT_GT(xs.DifferentialCrossSection(100.0, 0.1, 0.5, 0.105), 0.0); + // Energy outside the spline extent raises. + EXPECT_THROW(xs.DifferentialCrossSection(1.0e12, 0.1, 0.5, 0.105), std::runtime_error); + // (x, y) outside the spline grid raises (explicit Q2 bypasses the Q2 cut). + EXPECT_THROW(xs.DifferentialCrossSection(100.0, 1.0e-8, 0.5, 0.105, 5.0), std::runtime_error); +} +#endif // SIREN_HAS_PYTHIA8 + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/projects/interactions/private/test/QuarkDISDensityContract_TEST.cxx b/projects/interactions/private/test/QuarkDISDensityContract_TEST.cxx new file mode 100644 index 000000000..818da9f31 --- /dev/null +++ b/projects/interactions/private/test/QuarkDISDensityContract_TEST.cxx @@ -0,0 +1,51 @@ +// Contract tests for QuarkDISFromSpline (slow-rescaling charm DIS sampler). +// SampleFinalState also draws fragmentation z and azimuth phi, but the density +// covers (xi, y) only; z/phi cancel in the unbiased weight ratio. See +// QuarkDISFromSpline.h. No spline file needed: the no-arg ctor only builds the +// fragmentation pdf/cdf. +#include +#include +#include + +#include + +#include "SIREN/interactions/QuarkDISFromSpline.h" + +using namespace siren::interactions; + +TEST(QuarkDISDensityContract, ContractPinsTwoDensityVariables) { + QuarkDISFromSpline xs; + std::vector vars = xs.DensityVariables(); + + // Tripwire: density covers exactly (xi, y); z and phi are deliberately NOT + // in it. If this must change, re-derive the closure argument (z/phi cancel + // in the weight ratio) -- do not simply bump the count. + ASSERT_EQ(vars.size(), 2u); + EXPECT_EQ(vars[0], "Bjorken xi"); + EXPECT_EQ(vars[1], "Bjorken y"); +} + +// The fragmentation pdf sample_pdf(z) integrates to 1 over [0.001, 0.999]. +TEST(QuarkDISDensityContract, FragmentationPdfNormalized) { + QuarkDISFromSpline xs; + + // Composite-trapezoid on a fine grid; integrand is smooth and bounded away + // from the z=0,1 poles. + const double zlo = 0.001, zhi = 0.999; + const int M = 20000; + const double dz = (zhi - zlo) / M; + double integral = 0.0; + for (int i = 0; i <= M; ++i) { + double z = zlo + i * dz; + double w = (i == 0 || i == M) ? 0.5 : 1.0; + double f = xs.sample_pdf(z); + EXPECT_GE(f, 0.0); // pdf non-negative on its support + integral += w * f * dz; + } + EXPECT_NEAR(integral, 1.0, 1e-3); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/projects/interactions/public/SIREN/interactions/CharmCrossSectionHelpers.h b/projects/interactions/public/SIREN/interactions/CharmCrossSectionHelpers.h new file mode 100644 index 000000000..c1b0de7c9 --- /dev/null +++ b/projects/interactions/public/SIREN/interactions/CharmCrossSectionHelpers.h @@ -0,0 +1,110 @@ +#pragma once +#ifndef SIREN_CharmCrossSectionHelpers_H +#define SIREN_CharmCrossSectionHelpers_H + +// Shared, header-only helpers for the charm-DIS cross sections +// (QuarkDISFromSpline and PythiaDISCrossSection). These two classes are +// intentionally independent (no common base); this file holds only the byte- +// identical pieces, moved verbatim, so the values and behavior are unchanged. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "SIREN/dataclasses/Particle.h" +#include "SIREN/dataclasses/InteractionSignature.h" +#include "SIREN/utilities/Constants.h" + +namespace siren { +namespace interactions { +namespace charm_xsec { + +// D0:D+/-:Ds = 0.60:0.23:0.15, renormalized to sum to 1.0. The Lambda_c channel +// (~0.02) is not modeled, so its fraction is redistributed into the implemented +// D species (each /0.98); otherwise summing TotalCrossSection over the three +// registered D signatures recovers only 0.98*sigma_inclusive and under-counts +// charm production. Values are load-bearing for interaction-depth normalization. +inline double FragmentationFraction(siren::dataclasses::Particle::ParticleType secondary) { + if (secondary == siren::dataclasses::Particle::ParticleType::D0 || secondary == siren::dataclasses::Particle::ParticleType::D0Bar) { + return 0.6 / 0.98; + } else if (secondary == siren::dataclasses::Particle::ParticleType::DPlus || secondary == siren::dataclasses::Particle::ParticleType::DMinus) { + return 0.23 / 0.98; + } else if (secondary == siren::dataclasses::Particle::ParticleType::DsPlus || secondary == siren::dataclasses::Particle::ParticleType::DsMinus) { + return 0.15 / 0.98; + } + return 0; +} + +// Map a neutrino primary to its charged-lepton product (CC signature building). +// Throws on a non-neutrino input. The interaction-type policy (which classes +// accept CC/NC) stays in the caller; only this flavor map is shared. +inline siren::dataclasses::ParticleType ChargedLeptonProduct(siren::dataclasses::ParticleType primary_type) { + if(primary_type == siren::dataclasses::ParticleType::NuE) { + return siren::dataclasses::ParticleType::EMinus; + } else if(primary_type == siren::dataclasses::ParticleType::NuEBar) { + return siren::dataclasses::ParticleType::EPlus; + } else if(primary_type == siren::dataclasses::ParticleType::NuMu) { + return siren::dataclasses::ParticleType::MuMinus; + } else if(primary_type == siren::dataclasses::ParticleType::NuMuBar) { + return siren::dataclasses::ParticleType::MuPlus; + } else if(primary_type == siren::dataclasses::ParticleType::NuTau) { + return siren::dataclasses::ParticleType::TauMinus; + } else if(primary_type == siren::dataclasses::ParticleType::NuTauBar) { + return siren::dataclasses::ParticleType::TauPlus; + } else { + throw std::runtime_error("InitializeSignatures: Unknown parent neutrino type!"); + } +} + +// Lepton mass by |PDG| (neutrinos -> 0); throws on a non-lepton input. +inline double GetLeptonMass(siren::dataclasses::ParticleType lepton_type) { + int32_t lepton_number = std::abs(static_cast(lepton_type)); + switch(lepton_number) { + case 11: return siren::utilities::Constants::electronMass; + case 13: return siren::utilities::Constants::muonMass; + case 15: return siren::utilities::Constants::tauMass; + case 12: case 14: case 16: return 0; + default: throw std::runtime_error("Unknown lepton type!"); + } +} + +// Cross-section unit multiplier: "cm" -> 1, "m" -> 10000 (cm^2 internal). +inline double UnitForString(std::string units) { + std::transform(units.begin(), units.end(), units.begin(), + [](unsigned char c){ return std::tolower(c); }); + if(units == "cm") { + return 1.0; + } else if(units == "m") { + return 10000.0; + } else { + throw std::runtime_error("Cross section units not supported!"); + } +} + +inline std::vector ToVector(std::set const & types) { + return std::vector(types.begin(), types.end()); +} + +inline std::vector SignaturesForParents( + std::map, std::vector> const & by_parent, + siren::dataclasses::ParticleType primary_type, + siren::dataclasses::ParticleType target_type) { + std::pair key(primary_type, target_type); + auto it = by_parent.find(key); + if(it != by_parent.end()) { + return it->second; + } + return std::vector(); +} + +} // namespace charm_xsec +} // namespace interactions +} // namespace siren + +#endif // SIREN_CharmCrossSectionHelpers_H diff --git a/projects/interactions/public/SIREN/interactions/CharmDecayKinematics.h b/projects/interactions/public/SIREN/interactions/CharmDecayKinematics.h new file mode 100644 index 000000000..06bf2485b --- /dev/null +++ b/projects/interactions/public/SIREN/interactions/CharmDecayKinematics.h @@ -0,0 +1,209 @@ +#pragma once +#ifndef SIREN_CharmDecayKinematics_H +#define SIREN_CharmDecayKinematics_H + +// Shared closure kinematics for the charm-meson semileptonic decay samplers. +// +// CharmMesonDecay and CharmMesonDecay3Body are independent classes (no shared +// base), but their SampleFinalState <-> FinalStateProbability closure relies on +// the same q^2 density. These inline free helpers are the single source of that +// math so both classes stay byte-for-byte consistent. +// +// Closure rationale (authoritative copy): FinalStateProbability must be the +// normalized q^2 density that SampleFinalState produces, because the Weighter +// consumes it as the physical final-state density. The sampler draws +// m23 = sqrt(q^2) flat with accept-reject on the phase-space weight +// p1Abs*p23Abs, then (for D+/D0) accept-rejects on the V-A weight +// wtME = mD*E_l*(p_nu . p_K). The accepted m23 density is therefore proportional +// to p1Abs(m23)*p23Abs(m23)*_angle, and the q^2 density carries the +// Jacobian dm23/dq^2 = 1/(2 m23). SampledQ2Density reproduces exactly this; the +// per-event numerator and the normalization integral both call it, so +// Sample == Density by construction. The form-factor DifferentialDecayWidth is a +// separate physical quantity the sampler never used and is NOT the closure +// density. + +#include +#include +#include + +#include "SIREN/dataclasses/Particle.h" +#include "SIREN/utilities/Constants.h" +#include "SIREN/utilities/Integration.h" + +namespace siren { +namespace interactions { +namespace charm_decay { + +inline double particleMass(siren::dataclasses::ParticleType particle) { + switch(particle){ + case siren::dataclasses::ParticleType::D0: + return( siren::utilities::Constants::D0Mass); + case siren::dataclasses::ParticleType::D0Bar: + return( siren::utilities::Constants::D0Mass); + case siren::dataclasses::ParticleType::DPlus: + return( siren::utilities::Constants::DPlusMass); + case siren::dataclasses::ParticleType::DMinus: + return( siren::utilities::Constants::DPlusMass); + case siren::dataclasses::ParticleType::K0: + return( siren::utilities::Constants::K0Mass); + case siren::dataclasses::ParticleType::K0Bar: + return( siren::utilities::Constants::K0Mass); + case siren::dataclasses::ParticleType::KPlus: + return( siren::utilities::Constants::KPlusMass); + case siren::dataclasses::ParticleType::KMinus: + return( siren::utilities::Constants::KMinusMass); + case siren::dataclasses::ParticleType::EPlus: + return( siren::utilities::Constants::electronMass ); + case siren::dataclasses::ParticleType::EMinus: + return( siren::utilities::Constants::electronMass ); + case siren::dataclasses::ParticleType::MuPlus: + return( siren::utilities::Constants::muonMass ); + case siren::dataclasses::ParticleType::MuMinus: + return( siren::utilities::Constants::muonMass ); + case siren::dataclasses::ParticleType::TauPlus: + return( siren::utilities::Constants::tauMass ); + case siren::dataclasses::ParticleType::TauMinus: + return( siren::utilities::Constants::tauMass ); + case siren::dataclasses::ParticleType::DsPlus: + return( siren::utilities::Constants::DsPlusMass ); + case siren::dataclasses::ParticleType::DsMinus: + return( siren::utilities::Constants::DsMinusMass ); + default: + return(0.0); + } +} + +// Single source of truth for the K*(892) mass shared by the sampler and the +// FinalStateProbability normalizer. +inline double KStarMass() { + return siren::utilities::Constants::KPrimePlusMass; +} + +// Integral over [lo, hi] of max(0, a2*c^2 + a1*c + a0). Closed form with a +// linear/constant fallback for a2 ~ 0 and numerically stable quadratic roots. +// Used to evaluate the angle-averaged V-A weight analytically. +inline double integratePositivePartQuadratic(double a2, double a1, double a0, + double lo, double hi) { + if (hi <= lo) return 0.0; + auto F = [&](double c) { return a2 * c * c * c / 3.0 + a1 * c * c / 2.0 + a0 * c; }; + auto seg = [&](double x0, double x1) -> double { + if (x1 <= x0) return 0.0; + return F(x1) - F(x0); + }; + double scale = std::abs(a1) + std::abs(a0) + 1.0; + if (std::abs(a2) < 1e-12 * scale) { + // Linear q(c) = a1*c + a0 (or constant if a1 ~ 0). + if (std::abs(a1) < 1e-300) return (a0 > 0.0) ? seg(lo, hi) : 0.0; + double root = -a0 / a1; + if (a1 > 0.0) return seg(std::max(lo, root), hi); // q > 0 for c > root + return seg(lo, std::min(hi, root)); // q > 0 for c < root + } + double disc = a1 * a1 - 4.0 * a2 * a0; + if (disc <= 0.0) { + // No real roots: q keeps the sign of a2 everywhere. + return (a2 > 0.0) ? seg(lo, hi) : 0.0; + } + double sq = std::sqrt(disc); + double qq = -0.5 * (a1 + ((a1 >= 0.0) ? sq : -sq)); // stable roots + double r1 = qq / a2; + double r2 = a0 / qq; + double rlo = std::min(r1, r2), rhi = std::max(r1, r2); + if (a2 > 0.0) { + // q > 0 outside [rlo, rhi]. + return seg(lo, std::min(hi, rlo)) + seg(std::max(lo, rhi), hi); + } + // q > 0 inside (rlo, rhi). + return seg(std::max(lo, rlo), std::min(hi, rhi)); +} + +// Analytic angle-average of the sampler's ACCEPTED V-A weight, +// (1/2) * integral_{-1}^{1} clamp(wtME(c), 0, wtMEmax) dc, +// with c = cos(theta_lepton) in the (l,nu) rest frame. After the boost to the D +// rest frame wtME = pref*(a0 + a1 c + a2 c^2) is an exact quadratic, so +// clamp(q,0,M) = max(0,q) - max(0,q-M) gives two closed-form positive-part +// integrals (no quadrature error). The unit tests cross-check this against a +// numeric quadrature of the identical clamped weight. +inline double VAWeightAngleAverage(double mD, double mK, double ml, double m23) { + double mnu = 0.0; + double p1Abs = 0.5 * std::sqrt((mD - mK - m23) * (mD + mK + m23) + * (mD + mK - m23) * (mD - mK + m23)) / mD; + double p23Abs = 0.5 * std::sqrt((m23 - ml - mnu) * (m23 + ml + mnu) + * (m23 + ml - mnu) * (m23 - ml + mnu)) / m23; + if (p1Abs <= 0.0 || p23Abs <= 0.0) return 0.0; + double E23 = std::sqrt(p1Abs * p1Abs + m23 * m23); + double bz = -p1Abs / E23; // (l,nu)-system velocity along -kaon axis + double gamma = E23 / m23; + double Elrest = std::sqrt(p23Abs * p23Abs + ml * ml); + double EK = std::sqrt(p1Abs * p1Abs + mK * mK); + // E_l = gamma (C + D c); (p_nu . p_K) = gamma p23Abs (A + B c). + double C = Elrest; + double D = bz * p23Abs; + double A = EK - p1Abs * bz; + double B = p1Abs - EK * bz; + double pref = mD * gamma * gamma * p23Abs; // > 0 + if (pref <= 0.0) return 0.0; + // wtME = pref * q(c), q(c) = (C + D c)(A + B c) = a0 + a1 c + a2 c^2. + double a0 = C * A; + double a1 = C * B + D * A; + double a2 = D * B; + // Same matrix-element ceiling as SampleFinalState (meMode == 22). + double wtMEmax = std::min(std::pow(mD, 4) / 16.0, + mD * (mD - mK - ml) * (mD - mK - mnu) * (mD - ml - mnu)); + double qmax = wtMEmax / pref; + double integral = integratePositivePartQuadratic(a2, a1, a0, -1.0, 1.0) + - integratePositivePartQuadratic(a2, a1, a0 - qmax, -1.0, 1.0); + return 0.5 * pref * integral; // average over c (measure width 2) +} + +// Unnormalized sampler density in q^2 for one hadron-mass component. apply_va +// toggles the V-A angle-average (D+/D0) vs pure phase space (Ds). +inline double SampledQ2Density(double mD, double mK, double ml, double q2, bool apply_va) { + double m23 = std::sqrt(q2); + double m23Max = mD - mK; + if (m23 <= ml || m23 >= m23Max) return 0.0; + double mnu = 0.0; + double p1Abs = 0.5 * std::sqrt((mD - mK - m23) * (mD + mK + m23) + * (mD + mK - m23) * (mD - mK + m23)) / mD; + double p23Abs = 0.5 * std::sqrt((m23 - ml - mnu) * (m23 + ml + mnu) + * (m23 + ml - mnu) * (m23 - ml + mnu)) / m23; + if (p1Abs <= 0.0 || p23Abs <= 0.0) return 0.0; + // Phase-space weight WITH the sampler's rejection ceiling: where + // p1Abs*p23Abs exceeds wtPSmax the sampler saturates (always accepts), so the + // accepted density is flat at wtPSmax. Reproduce the clip for exact closure. + double m23Min = ml + mnu; + double p1Max = 0.5 * std::sqrt((mD - mK - m23Min) * (mD + mK + m23Min) + * (mD + mK - m23Min) * (mD - mK + m23Min)) / mD; + double p23Max = 0.5 * std::sqrt((m23Max - ml - mnu) * (m23Max + ml + mnu) + * (m23Max + ml - mnu) * (m23Max - ml + mnu)) / m23Max; + double wtPSmax = 0.5 * p1Max * p23Max; + double wtPS = p1Abs * p23Abs; + if (wtPS > wtPSmax) wtPS = wtPSmax; + double me = apply_va ? VAWeightAngleAverage(mD, mK, ml, m23) : 1.0; + // Jacobian dm23/dq^2 = 1/(2 m23) converts the m23 density to a q^2 density. + return wtPS * me / (2.0 * m23); +} + +// Integral of SampledQ2Density over the allowed q^2 range, normalizing each +// mixture component to a proper pdf. Cached in norm_cache per (mD, mK, ml, +// apply_va) so the per-event FinalStateProbability call does not re-integrate. +inline double SampledQ2Normalization(double mD, double mK, double ml, bool apply_va, + std::map & norm_cache) { + double key = ((mD * 1e3 + mK) * 1e3 + ml) * 2.0 + (apply_va ? 1.0 : 0.0); + long ikey = (long)std::llround(key * 1e3); + auto it = norm_cache.find(ikey); + if (it != norm_cache.end()) return it->second; + double q2min = ml * ml; + double q2max = (mD - mK) * (mD - mK); + std::function integrand = [&](double q2) -> double { + return SampledQ2Density(mD, mK, ml, q2, apply_va); + }; + double n = siren::utilities::rombergIntegrate(integrand, q2min, q2max); + norm_cache[ikey] = n; + return n; +} + +} // namespace charm_decay +} // namespace interactions +} // namespace siren + +#endif // SIREN_CharmDecayKinematics_H diff --git a/projects/interactions/public/SIREN/interactions/CharmMesonDecay.h b/projects/interactions/public/SIREN/interactions/CharmMesonDecay.h new file mode 100644 index 000000000..d0b71b72a --- /dev/null +++ b/projects/interactions/public/SIREN/interactions/CharmMesonDecay.h @@ -0,0 +1,93 @@ +#pragma once +#ifndef SIREN_CharmMesonDecay_H +#define SIREN_CharmMesonDecay_H + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + + +#include "SIREN/dataclasses/Particle.h" +#include "SIREN/dataclasses/InteractionSignature.h" +#include "SIREN/dataclasses/InteractionRecord.h" + +#include "SIREN/interactions/Decay.h" +#include "SIREN/utilities/Interpolator.h" + + +namespace siren { +namespace interactions { + +class CharmMesonDecay : public Decay { +friend cereal::access; +private: + const std::set primary_types = {siren::dataclasses::Particle::ParticleType::D0, siren::dataclasses::Particle::ParticleType::DPlus, siren::dataclasses::Particle::ParticleType::DsPlus, siren::dataclasses::Particle::ParticleType::D0Bar, siren::dataclasses::Particle::ParticleType::DMinus, siren::dataclasses::Particle::ParticleType::DsMinus}; + // Per-component q^2-normalization cache (not serialized; keyed by mass set). + // Closure helpers live in charm_decay:: (CharmDecayKinematics.h). + mutable std::map norm_cache; +public: + CharmMesonDecay(); + CharmMesonDecay(siren::dataclasses::Particle::ParticleType primary); + virtual bool equal(Decay const & other) const override; + double TotalDecayWidth(dataclasses::InteractionRecord const &) const override; + double TotalDecayWidth(siren::dataclasses::Particle::ParticleType primary) const override; + double TotalDecayWidthForFinalState(dataclasses::InteractionRecord const &) const override; + double DifferentialDecayWidth(dataclasses::InteractionRecord const &) const override; + void SampleFinalStateHadronic(dataclasses::CrossSectionDistributionRecord &, std::shared_ptr) const; + void SampleFinalState(dataclasses::CrossSectionDistributionRecord &, std::shared_ptr) const override; + std::vector GetPossibleSignatures() const override; + std::vector GetPossibleSignaturesFromParent(siren::dataclasses::Particle::ParticleType primary) const override; + virtual double FinalStateProbability(dataclasses::InteractionRecord const & record) const override; + +public: + virtual std::vector DensityVariables() const override; + template + void save(Archive & archive, std::uint32_t const version) const { + if(version == 0) { + archive(::cereal::make_nvp("PrimaryTypes", primary_types)); + archive(::cereal::make_nvp("Decay", cereal::virtual_base_class(this))); + } else { + throw std::runtime_error("CharmMesonDecay only supports version <= 0!"); + } + } + template + void load(Archive & archive, std::uint32_t version) { + if(version == 0) { + // CharmMesonDecay is default-constructible and primary_types is a + // fixed const member, so cereal default-constructs then calls this + // load(). The archived PrimaryTypes is read into a temporary to + // consume the stream symmetrically with save() (its value is + // invariant across instances). A load_and_construct here would be + // bypassed for a default-constructible type, leaving the body + // unread and corrupting any following data in the archive. + std::set _primary_types; + archive(::cereal::make_nvp("PrimaryTypes", _primary_types)); + archive(::cereal::make_nvp("Decay", cereal::virtual_base_class(this))); + } else { + throw std::runtime_error("CharmMesonDecay only supports version <= 0!"); + } + } + +}; + +} // namespace interactions +} // namespace siren + +CEREAL_CLASS_VERSION(siren::interactions::CharmMesonDecay, 0); +CEREAL_REGISTER_TYPE(siren::interactions::CharmMesonDecay); +CEREAL_REGISTER_POLYMORPHIC_RELATION(siren::interactions::Decay, siren::interactions::CharmMesonDecay); + +#endif // SIREN_CharmMesonDecay_H diff --git a/projects/interactions/public/SIREN/interactions/CharmMesonDecay3Body.h b/projects/interactions/public/SIREN/interactions/CharmMesonDecay3Body.h new file mode 100644 index 000000000..a36a07029 --- /dev/null +++ b/projects/interactions/public/SIREN/interactions/CharmMesonDecay3Body.h @@ -0,0 +1,97 @@ +#pragma once +#ifndef SIREN_CharmMesonDecay3Body_H +#define SIREN_CharmMesonDecay3Body_H + +// CharmMesonDecay3Body -- Pythia-style 3-body phase-space decay for D0/D+. +// Sampler draws final-state kinematics from 3-body phase space +// (Pythia ParticleDecays::threeBody) with V-A matrix-element reweighting and +// per-event K / K*(892) kinematic mixing (PDG branching ratios). Independent of +// CharmMesonDecay; shares only the inline charm_decay:: closure kinematics. + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + + +#include "SIREN/dataclasses/Particle.h" +#include "SIREN/dataclasses/InteractionSignature.h" +#include "SIREN/dataclasses/InteractionRecord.h" + +#include "SIREN/interactions/Decay.h" +#include "SIREN/utilities/Interpolator.h" + + +namespace siren { +namespace interactions { + +class CharmMesonDecay3Body : public Decay { +friend cereal::access; +private: + const std::set primary_types = {siren::dataclasses::Particle::ParticleType::D0, siren::dataclasses::Particle::ParticleType::DPlus}; + // Per-component q^2-normalization cache (not serialized; keyed by mass set). + // Closure helpers live in charm_decay:: (CharmDecayKinematics.h). + mutable std::map norm_cache; +public: + CharmMesonDecay3Body(); + CharmMesonDecay3Body(siren::dataclasses::Particle::ParticleType primary); + virtual bool equal(Decay const & other) const override; + double TotalDecayWidth(dataclasses::InteractionRecord const &) const override; + double TotalDecayWidth(siren::dataclasses::Particle::ParticleType primary) const override; + double TotalDecayWidthForFinalState(dataclasses::InteractionRecord const &) const override; + double DifferentialDecayWidth(dataclasses::InteractionRecord const &) const override; + void SampleFinalStateHadronic(dataclasses::CrossSectionDistributionRecord &, std::shared_ptr) const; + void SampleFinalState(dataclasses::CrossSectionDistributionRecord &, std::shared_ptr) const override; + std::vector GetPossibleSignatures() const override; + std::vector GetPossibleSignaturesFromParent(siren::dataclasses::Particle::ParticleType primary) const override; + virtual double FinalStateProbability(dataclasses::InteractionRecord const & record) const override; + +public: + virtual std::vector DensityVariables() const override; + template + void save(Archive & archive, std::uint32_t const version) const { + if(version == 0) { + archive(::cereal::make_nvp("PrimaryTypes", primary_types)); + archive(::cereal::make_nvp("Decay", cereal::virtual_base_class(this))); + } else { + throw std::runtime_error("CharmMesonDecay3Body only supports version <= 0!"); + } + } + template + void load(Archive & archive, std::uint32_t version) { + if(version == 0) { + // Default-constructible with a fixed const primary_types, so cereal + // default-constructs then calls this load(). Read PrimaryTypes into a + // temporary to consume the stream symmetrically with save(); a + // load_and_construct would be bypassed for a default-constructible + // type, leaving the body unread and corrupting following archive data. + std::set _primary_types; + archive(::cereal::make_nvp("PrimaryTypes", _primary_types)); + archive(::cereal::make_nvp("Decay", cereal::virtual_base_class(this))); + } else { + throw std::runtime_error("CharmMesonDecay3Body only supports version <= 0!"); + } + } + +}; + +} // namespace interactions +} // namespace siren + +CEREAL_CLASS_VERSION(siren::interactions::CharmMesonDecay3Body, 0); +CEREAL_REGISTER_TYPE(siren::interactions::CharmMesonDecay3Body); +CEREAL_REGISTER_POLYMORPHIC_RELATION(siren::interactions::Decay, siren::interactions::CharmMesonDecay3Body); + +#endif // SIREN_CharmMesonDecay3Body_H diff --git a/projects/interactions/public/SIREN/interactions/DMesonELoss.h b/projects/interactions/public/SIREN/interactions/DMesonELoss.h new file mode 100644 index 000000000..229386bf9 --- /dev/null +++ b/projects/interactions/public/SIREN/interactions/DMesonELoss.h @@ -0,0 +1,103 @@ +#pragma once +#ifndef SIREN_DMesonELoss_H +#define SIREN_DMesonELoss_H + +#include // for set +#include // for map +#include +#include // for vector +#include // for uint32_t +#include // for pair +#include +#include // for runtime... + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "SIREN/interactions/CrossSection.h" // for CrossSe... +#include "SIREN/dataclasses/InteractionSignature.h" // for Interac... +#include "SIREN/dataclasses/Particle.h" // for Particle + +namespace siren { namespace dataclasses { class InteractionRecord; } } +namespace siren { namespace utilities { class SIREN_random; } } + +namespace siren { +namespace interactions { + +class DMesonELoss : public CrossSection { +friend cereal::access; +private: + std::set primary_types_ = {siren::dataclasses::Particle::ParticleType::D0, siren::dataclasses::Particle::ParticleType::DPlus, siren::dataclasses::Particle::ParticleType::DsPlus, siren::dataclasses::Particle::ParticleType::D0Bar, siren::dataclasses::Particle::ParticleType::DMinus, siren::dataclasses::Particle::ParticleType::DsMinus}; + std::set target_types_ = {siren::dataclasses::Particle::ParticleType::PPlus}; + + // Truncation bounds for the inelasticity z, shared by SampleFinalState + // (rejection) and the density (normalization). The actual upper limit is the + // energy-dependent kinematic cut z <= 1 - mD/E; the Gaussian is normalized over + // [z_min_, min(z_max_, 1 - mD/E)] so supports match at every energy (closure). + // z_min_ floors z > 0 (no energy gain, away from the null-recoil degeneracy). + static constexpr double z_min_ = 0.001; + static constexpr double z_max_ = 1.0; + +public: + DMesonELoss(); + + virtual bool equal(CrossSection const & other) const override; + + double TotalCrossSection(dataclasses::InteractionRecord const &) const override; + double TotalCrossSection(siren::dataclasses::Particle::ParticleType primary, double energy) const; + + double DifferentialCrossSection(dataclasses::InteractionRecord const &) const override; + double InteractionThreshold(dataclasses::InteractionRecord const &) const override; + void SampleFinalState(dataclasses::CrossSectionDistributionRecord &, std::shared_ptr random) const override; + + std::vector GetPossibleTargets() const override; + std::vector GetPossibleTargetsFromPrimary(siren::dataclasses::Particle::ParticleType primary_type) const override; + std::vector GetPossiblePrimaries() const override; + std::vector GetPossibleSignatures() const override; + std::vector GetPossibleSignaturesFromParents(siren::dataclasses::Particle::ParticleType primary_type, siren::dataclasses::Particle::ParticleType target_type) const override; + + virtual double FinalStateProbability(dataclasses::InteractionRecord const & record) const override; + +public: + virtual std::vector DensityVariables() const override; + template + void save(Archive & archive, std::uint32_t const version) const { + if(version == 0) { + archive(::cereal::make_nvp("PrimaryTypes", primary_types_)); + archive(cereal::virtual_base_class(this)); + } else { + throw std::runtime_error("DMesonELoss only supports version <= 0!"); + } + } + template + void load(Archive & archive, std::uint32_t version) { + if(version == 0) { + archive(::cereal::make_nvp("PrimaryTypes", primary_types_)); + archive(cereal::virtual_base_class(this)); + InitializeSignatures(); + } else { + throw std::runtime_error("DMesonELoss only supports version <= 0!"); + } + } +private: + void InitializeSignatures(); +}; + +} // namespace interactions +} // namespace siren + +CEREAL_CLASS_VERSION(siren::interactions::DMesonELoss, 0); +CEREAL_REGISTER_TYPE(siren::interactions::DMesonELoss); +CEREAL_REGISTER_POLYMORPHIC_RELATION(siren::interactions::CrossSection, siren::interactions::DMesonELoss); + +#endif // SIREN_DMesonELoss_H diff --git a/projects/interactions/public/SIREN/interactions/ElasticScattering.h b/projects/interactions/public/SIREN/interactions/ElasticScattering.h index 38cc95b1d..7870442ed 100644 --- a/projects/interactions/public/SIREN/interactions/ElasticScattering.h +++ b/projects/interactions/public/SIREN/interactions/ElasticScattering.h @@ -64,7 +64,7 @@ friend cereal::access; } } template - void load_and_construct(Archive & archive, cereal::construct & construct, std::uint32_t version) { + static void load_and_construct(Archive & archive, cereal::construct & construct, std::uint32_t version) { if(version == 0) { std::set _primary_types; archive(::cereal::make_nvp("PrimaryTypes", _primary_types)); diff --git a/projects/interactions/public/SIREN/interactions/ElectroweakDecay.h b/projects/interactions/public/SIREN/interactions/ElectroweakDecay.h index b68b3596e..37e39e708 100644 --- a/projects/interactions/public/SIREN/interactions/ElectroweakDecay.h +++ b/projects/interactions/public/SIREN/interactions/ElectroweakDecay.h @@ -96,7 +96,7 @@ friend cereal::access; } } template - void load_and_construct(Archive & archive, cereal::construct & construct, std::uint32_t version) { + static void load_and_construct(Archive & archive, cereal::construct & construct, std::uint32_t version) { if(version == 0) { std::set _primary_types; archive(::cereal::make_nvp("PrimaryTypes", _primary_types)); diff --git a/projects/interactions/public/SIREN/interactions/HNLDecay.h b/projects/interactions/public/SIREN/interactions/HNLDecay.h index 284a8df76..e9c0c2cd1 100644 --- a/projects/interactions/public/SIREN/interactions/HNLDecay.h +++ b/projects/interactions/public/SIREN/interactions/HNLDecay.h @@ -117,7 +117,7 @@ HNLDecay(): crundec(new CRunDec()){}; } } template - void load_and_construct(Archive & archive, cereal::construct & construct, std::uint32_t version) { + static void load_and_construct(Archive & archive, cereal::construct & construct, std::uint32_t version) { if(version == 0) { std::set _primary_types; double _hnl_mass; diff --git a/projects/interactions/public/SIREN/interactions/HNLDipoleDecay.h b/projects/interactions/public/SIREN/interactions/HNLDipoleDecay.h index 73b6d4623..d5c0e57cb 100644 --- a/projects/interactions/public/SIREN/interactions/HNLDipoleDecay.h +++ b/projects/interactions/public/SIREN/interactions/HNLDipoleDecay.h @@ -75,7 +75,7 @@ HNLDipoleDecay() {}; } } template - void load_and_construct(Archive & archive, cereal::construct & construct, std::uint32_t version) { + static void load_and_construct(Archive & archive, cereal::construct & construct, std::uint32_t version) { if(version == 0) { std::set _primary_types; double _hnl_mass; diff --git a/projects/interactions/public/SIREN/interactions/HNLDipoleFromTable.h b/projects/interactions/public/SIREN/interactions/HNLDipoleFromTable.h index f02504477..344cabec0 100644 --- a/projects/interactions/public/SIREN/interactions/HNLDipoleFromTable.h +++ b/projects/interactions/public/SIREN/interactions/HNLDipoleFromTable.h @@ -98,7 +98,7 @@ HNLDipoleFromTable() {}; } } template - void load_and_construct(Archive & archive, cereal::construct & construct, std::uint32_t version) { + static void load_and_construct(Archive & archive, cereal::construct & construct, std::uint32_t version) { if(version == 0) { bool _z_samp = true; bool _in_invGeV = true; diff --git a/projects/interactions/public/SIREN/interactions/PythiaDISCrossSection.h b/projects/interactions/public/SIREN/interactions/PythiaDISCrossSection.h new file mode 100644 index 000000000..45a6ae713 --- /dev/null +++ b/projects/interactions/public/SIREN/interactions/PythiaDISCrossSection.h @@ -0,0 +1,240 @@ +#pragma once +#ifndef SIREN_PythiaDISCrossSection_H +#define SIREN_PythiaDISCrossSection_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "SIREN/interactions/CrossSection.h" +#include "SIREN/dataclasses/InteractionSignature.h" +#include "SIREN/dataclasses/Particle.h" + +// Forward declarations for Pythia +namespace Pythia8 { + class Pythia; +} + +namespace siren { namespace dataclasses { class InteractionRecord; } } +namespace siren { namespace utilities { class SIREN_random; } } + +namespace siren { +namespace interactions { + +// Forward declaration -- defined in .cxx +class SIRENRndm; + +class PythiaDISCrossSection : public CrossSection { +friend cereal::access; +private: + // Splines for total/differential cross section (SIREN weighting). + // The total spline is always required (drives interaction depth / position / + // survival). The differential spline is OPTIONAL: when absent, + // FinalStateProbability returns a constant that cancels in the unbiased + // weight (final state comes from Pythia, injection == physical). + photospline::splinetable<> differential_cross_section_; + photospline::splinetable<> total_cross_section_; + bool has_differential_ = false; + + // Pythia instance (mutable because SampleFinalState is const) + mutable std::unique_ptr pythia_; + mutable std::shared_ptr siren_rndm_; + + // Signature bookkeeping + std::vector signatures_; + std::set primary_types_; + std::set target_types_; + std::map, std::vector> signatures_by_parent_types_; + + // DIS parameters + int interaction_type_; // 1=CC, 2=NC + double target_mass_; + double minimum_Q2_; + double unit; + + // Pythia configuration + std::string pdf_set_; + std::string pythia_data_path_; + + // Helper methods + void InitializePythia(double E_nu, int target_pdg) const; + void InitializeSignatures(); + void LoadFromFile(std::string differential_filename, std::string total_filename); + void LoadFromMemory(std::vector & differential_data, std::vector & total_data); + void ReadParamsFromSplineTable(); + void SetUnits(std::string units); + + // Particle ID helpers + static bool IsCharmedHadron(int pdgId); + static siren::dataclasses::ParticleType PdgToParticleType(int pdgId); + static double GetLeptonMass(siren::dataclasses::ParticleType lepton_type); + static std::map getIndices(siren::dataclasses::InteractionSignature signature); + +public: + PythiaDISCrossSection(); + ~PythiaDISCrossSection(); + + // Main constructor + PythiaDISCrossSection( + std::string differential_filename, + std::string total_filename, + int interaction_type, + double target_mass, + double minimum_Q2, + std::set primary_types, + std::set target_types, + std::string pythia_data_path, + std::string pdf_set = "LHAPDF6:HERAPDF20_NLO_EIG", + std::string units = "cm" + ); + + // Constructor with vectors + PythiaDISCrossSection( + std::string differential_filename, + std::string total_filename, + int interaction_type, + double target_mass, + double minimum_Q2, + std::vector primary_types, + std::vector target_types, + std::string pythia_data_path, + std::string pdf_set = "LHAPDF6:HERAPDF20_NLO_EIG", + std::string units = "cm" + ); + + virtual bool equal(CrossSection const & other) const override; + + // Cross section from splines + double TotalCrossSection(dataclasses::InteractionRecord const &) const override; + double TotalCrossSection(siren::dataclasses::ParticleType primary, double energy) const; + double DifferentialCrossSection(dataclasses::InteractionRecord const &) const override; + double DifferentialCrossSection(double energy, double x, double y, double secondary_lepton_mass, double Q2=std::numeric_limits::quiet_NaN()) const; + double InteractionThreshold(dataclasses::InteractionRecord const &) const override; + + // Fragmentation fraction (from Pythia output statistics) + double FragmentationFraction(siren::dataclasses::Particle::ParticleType secondary) const; + + // Final state sampling via Pythia + void SampleFinalState(dataclasses::CrossSectionDistributionRecord &, std::shared_ptr random) const override; + + // Generate raw Pythia charm-DIS samples for building total/differential + // splines (init once per energy, using the same Pythia config as + // SampleFinalState). out_sigma_mb is per energy (Pythia's generated cross + // section, mb); out_E/out_x/out_y are flat per-event muon-reconstructed + // (E, Bjorken x, y). Consumed by the python generate_*_spline helpers. + static void GeneratePythiaCharmSamples( + int interaction_type, int primary_pdg, int target_pdg, double target_mass, + std::string pdf_set, std::string pythia_data_path, double minimum_Q2, + std::vector const & energies, int n_events, + std::vector & out_sigma_mb, + std::vector & out_E, std::vector & out_x, std::vector & out_y); + + // Signature methods + std::vector GetPossibleTargets() const override; + std::vector GetPossibleTargetsFromPrimary(siren::dataclasses::ParticleType primary_type) const override; + std::vector GetPossiblePrimaries() const override; + std::vector GetPossibleSignatures() const override; + std::vector GetPossibleSignaturesFromParents(siren::dataclasses::ParticleType primary_type, siren::dataclasses::ParticleType target_type) const override; + virtual double FinalStateProbability(dataclasses::InteractionRecord const & record) const override; + virtual std::vector DensityVariables() const override; + + // Getters + double GetMinimumQ2() const { return minimum_Q2_; } + double GetTargetMass() const { return target_mass_; } + int GetInteractionType() const { return interaction_type_; } + +public: + template + void save(Archive & archive, std::uint32_t const version) const { + if(version == 0) { + splinetable_buffer buf; + archive(::cereal::make_nvp("HasDifferential", has_differential_)); + + // The differential spline is optional; only serialize it when present. + if(has_differential_) { + buf.size = 0; + auto diff_obj = differential_cross_section_.write_fits_mem(); + buf.data = diff_obj.first; + buf.size = diff_obj.second; + std::vector diff_blob; + diff_blob.resize(buf.size); + std::copy((char*)buf.data, (char*)buf.data + buf.size, &diff_blob[0]); + archive(::cereal::make_nvp("DifferentialCrossSectionSpline", diff_blob)); + } + + buf.size = 0; + auto result_obj = total_cross_section_.write_fits_mem(); + buf.data = result_obj.first; + buf.size = result_obj.second; + + std::vector total_blob; + total_blob.resize(buf.size); + std::copy((char*)buf.data, (char*)buf.data + buf.size, &total_blob[0]); + + archive(::cereal::make_nvp("TotalCrossSectionSpline", total_blob)); + archive(::cereal::make_nvp("PrimaryTypes", primary_types_)); + archive(::cereal::make_nvp("TargetTypes", target_types_)); + archive(::cereal::make_nvp("InteractionType", interaction_type_)); + archive(::cereal::make_nvp("TargetMass", target_mass_)); + archive(::cereal::make_nvp("MinimumQ2", minimum_Q2_)); + archive(::cereal::make_nvp("Unit", unit)); + archive(::cereal::make_nvp("PdfSet", pdf_set_)); + archive(::cereal::make_nvp("PythiaDataPath", pythia_data_path_)); + archive(cereal::virtual_base_class(this)); + } else { + throw std::runtime_error("PythiaDISCrossSection only supports version <= 0!"); + } + } + template + void load(Archive & archive, std::uint32_t version) { + if(version == 0) { + std::vector differential_data; + std::vector total_data; + archive(::cereal::make_nvp("HasDifferential", has_differential_)); + if(has_differential_) { + archive(::cereal::make_nvp("DifferentialCrossSectionSpline", differential_data)); + } + archive(::cereal::make_nvp("TotalCrossSectionSpline", total_data)); + archive(::cereal::make_nvp("PrimaryTypes", primary_types_)); + archive(::cereal::make_nvp("TargetTypes", target_types_)); + archive(::cereal::make_nvp("InteractionType", interaction_type_)); + archive(::cereal::make_nvp("TargetMass", target_mass_)); + archive(::cereal::make_nvp("MinimumQ2", minimum_Q2_)); + archive(::cereal::make_nvp("Unit", unit)); + archive(::cereal::make_nvp("PdfSet", pdf_set_)); + archive(::cereal::make_nvp("PythiaDataPath", pythia_data_path_)); + archive(cereal::virtual_base_class(this)); + LoadFromMemory(differential_data, total_data); + InitializeSignatures(); + } else { + throw std::runtime_error("PythiaDISCrossSection only supports version <= 0!"); + } + } +}; + +} // namespace interactions +} // namespace siren + +CEREAL_CLASS_VERSION(siren::interactions::PythiaDISCrossSection, 0); +CEREAL_REGISTER_TYPE(siren::interactions::PythiaDISCrossSection); +CEREAL_REGISTER_POLYMORPHIC_RELATION(siren::interactions::CrossSection, siren::interactions::PythiaDISCrossSection); + +#endif // SIREN_PythiaDISCrossSection_H diff --git a/projects/interactions/public/SIREN/interactions/QuarkDISFromSpline.h b/projects/interactions/public/SIREN/interactions/QuarkDISFromSpline.h new file mode 100644 index 000000000..7758619ca --- /dev/null +++ b/projects/interactions/public/SIREN/interactions/QuarkDISFromSpline.h @@ -0,0 +1,200 @@ +#pragma once +#ifndef SIREN_QuarkDISFromSpline_H +#define SIREN_QuarkDISFromSpline_H + +#include // for set +#include // for map +#include +#include // for vector +#include // for uint32_t +#include // for pair +#include +#include // for numeric_limits (NaN default arg) +#include // for runtime... + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "SIREN/interactions/CrossSection.h" // for CrossSe... +#include "SIREN/dataclasses/InteractionSignature.h" // for Interac... +#include "SIREN/dataclasses/Particle.h" // for Particle +#include "SIREN/utilities/Interpolator.h" +#include "SIREN/utilities/Integration.h" + +namespace siren { namespace dataclasses { class InteractionRecord; } } +namespace siren { namespace utilities { class SIREN_random; } } + +namespace siren { +namespace interactions { + +/// Slow-rescaling charm DIS sampler. Expects FITS splines tabulated as +/// dsdxidy(log10 E, log10 xi, log10 y), e.g. Maboi_M_Muon_SR/dsdxidy_*.fits. +/// Charm-quark mass m_c=1.280 GeV (Constants::charmMass) and lightest charm +/// hadron M_D0=1.86484 GeV (Constants::D0Mass) are taken from +/// siren::utilities::Constants and are not configurable per-instance. +/// +/// UNBIASED-ONLY CONTRACT: SampleFinalState samples (xi,y) plus an independent +/// fragmentation z and uniform azimuth phi that set the D-meson momentum, but +/// DensityVariables/FinalStateProbability/DifferentialCrossSection account for +/// (xi,y) only. The omitted z/phi factors cancel in the weight ratio ONLY when +/// the same cross-section object supplies both the injection and physical +/// densities and no biased phase-space channel is installed on the D kinematics. +/// Biasing the D kinematics is NOT supported and will produce incorrect weights. +/// +/// NORMALIZATION CONTRACT: FinalStateProbability = dxs/txs is a normalized +/// kinematic density only if the external 1-D total-xs spline integrates the +/// SAME truncated slow-rescaling (xi,y) domain (and the same charm-threshold, +/// W2, Q2>=minimum_Q2_ cuts and TARGETMASS) as the differential spline. +class QuarkDISFromSpline : public CrossSection { +friend cereal::access; +private: + photospline::splinetable<> differential_cross_section_; + photospline::splinetable<> total_cross_section_; + + std::vector signatures_; + std::set primary_types_; + std::set target_types_; + std::map, std::vector> signatures_by_parent_types_; + + // used by the DIS process + int interaction_type_; + double target_mass_; + double minimum_Q2_; + + // used by the hadronization process + double fragmentation_integral = 0; // for storing the integrated unnormed pdf + void normalize_pdf(); // for normalizing pdf and integral, to be called at initialization + siren::utilities::Interpolator1D inverseCdfTable; // for storing the CDF-1 table for the hadronization + + double unit; + +public: + QuarkDISFromSpline(); + QuarkDISFromSpline(std::vector differential_data, std::vector total_data, int interaction, double target_mass, double minumum_Q2, std::set primary_types, std::set target_types, std::string units = "cm"); + QuarkDISFromSpline(std::vector differential_data, std::vector total_data, int interaction, double target_mass, double minumum_Q2, std::vector primary_types, std::vector target_types, std::string units = "cm"); + QuarkDISFromSpline(std::string differential_filename, std::string total_filename, int interaction, double target_mass, double minumum_Q2, std::set primary_types, std::set target_types, std::string units = "cm"); + QuarkDISFromSpline(std::string differential_filename, std::string total_filename, std::set primary_types, std::set target_types, std::string units = "cm"); + QuarkDISFromSpline(std::string differential_filename, std::string total_filename, int interaction, double target_mass, double minumum_Q2, std::vector primary_types, std::vector target_types, std::string units = "cm"); + QuarkDISFromSpline(std::string differential_filename, std::string total_filename, std::vector primary_types, std::vector target_types, std::string units = "cm"); + + void SetUnits(std::string units); + void SetInteractionType(int interaction); + + virtual bool equal(CrossSection const & other) const override; + + // function definitions needed to compute the DIS vertex + double TotalCrossSection(dataclasses::InteractionRecord const &) const override; + double TotalCrossSection(siren::dataclasses::ParticleType primary, double energy) const; + double DifferentialCrossSection(dataclasses::InteractionRecord const &) const override; + double DifferentialCrossSection(double energy, double xi, double y, double secondary_lepton_mass, double Q2=std::numeric_limits::quiet_NaN()) const; + double InteractionThreshold(dataclasses::InteractionRecord const &) const override; + + // function definitions needed to compute the hadronization vertex + double FragmentationFraction(siren::dataclasses::Particle::ParticleType secondary) const; + double sample_pdf(double z) const; + void compute_cdf(); + static double getHadronMass(siren::dataclasses::ParticleType hadron_type); + + // used for both processes + void SampleFinalState(dataclasses::CrossSectionDistributionRecord &, std::shared_ptr random) const override; + std::vector GetPossibleTargets() const override; + std::vector GetPossibleTargetsFromPrimary(siren::dataclasses::ParticleType primary_type) const override; + std::vector GetPossiblePrimaries() const override; + std::vector GetPossibleSignatures() const override; + std::vector GetPossibleSignaturesFromParents(siren::dataclasses::ParticleType primary_type, siren::dataclasses::ParticleType target_type) const override; + virtual double FinalStateProbability(dataclasses::InteractionRecord const & record) const override; + + // other utility functions + void LoadFromFile(std::string differential_filename, std::string total_filename); + void LoadFromMemory(std::vector & differential_data, std::vector & total_data); + + // utilities for DIS parametrs + double GetMinimumQ2() const {return minimum_Q2_;}; + double GetTargetMass() const {return target_mass_;}; + int GetInteractionType() const {return interaction_type_;}; + static double GetLeptonMass(siren::dataclasses::ParticleType lepton_type); + static std::map getIndices(siren::dataclasses::InteractionSignature signature); + + +public: + virtual std::vector DensityVariables() const override; + template + void save(Archive & archive, std::uint32_t const version) const { + if(version == 0) { + splinetable_buffer buf; + buf.size = 0; + auto result_obj = differential_cross_section_.write_fits_mem(); + buf.data = result_obj.first; + buf.size = result_obj.second; + + std::vector diff_blob; + diff_blob.resize(buf.size); + std::copy((char*)buf.data, (char*)buf.data + buf.size, &diff_blob[0]); + + archive(::cereal::make_nvp("DifferentialCrossSectionSpline", diff_blob)); + + buf.size = 0; + result_obj = total_cross_section_.write_fits_mem(); + buf.data = result_obj.first; + buf.size = result_obj.second; + + std::vector total_blob; + total_blob.resize(buf.size); + std::copy((char*)buf.data, (char*)buf.data + buf.size, &total_blob[0]); + + archive(::cereal::make_nvp("TotalCrossSectionSpline", total_blob)); + archive(::cereal::make_nvp("PrimaryTypes", primary_types_)); + archive(::cereal::make_nvp("TargetTypes", target_types_)); + archive(::cereal::make_nvp("InteractionType", interaction_type_)); + archive(::cereal::make_nvp("TargetMass", target_mass_)); + archive(::cereal::make_nvp("MinimumQ2", minimum_Q2_)); + archive(::cereal::make_nvp("Unit", unit)); + archive(cereal::virtual_base_class(this)); + } else { + throw std::runtime_error("QuarkDISFromSpline only supports version <= 0!"); + } + } + template + void load(Archive & archive, std::uint32_t version) { + if(version == 0) { + std::vector differential_data; + std::vector total_data; + archive(::cereal::make_nvp("DifferentialCrossSectionSpline", differential_data)); + archive(::cereal::make_nvp("TotalCrossSectionSpline", total_data)); + archive(::cereal::make_nvp("PrimaryTypes", primary_types_)); + archive(::cereal::make_nvp("TargetTypes", target_types_)); + archive(::cereal::make_nvp("InteractionType", interaction_type_)); + archive(::cereal::make_nvp("TargetMass", target_mass_)); + archive(::cereal::make_nvp("MinimumQ2", minimum_Q2_)); + archive(::cereal::make_nvp("Unit", unit)); + archive(cereal::virtual_base_class(this)); + LoadFromMemory(differential_data, total_data); + InitializeSignatures(); + } else { + throw std::runtime_error("QuarkDISFromSpline only supports version <= 0!"); + } + } +private: + void ReadParamsFromSplineTable(); + void InitializeSignatures(); + static std::set DTypesForPrimary(siren::dataclasses::ParticleType primary); +}; + +} // namespace interactions +} // namespace siren + +CEREAL_CLASS_VERSION(siren::interactions::QuarkDISFromSpline, 0); +CEREAL_REGISTER_TYPE(siren::interactions::QuarkDISFromSpline); +CEREAL_REGISTER_POLYMORPHIC_RELATION(siren::interactions::CrossSection, siren::interactions::QuarkDISFromSpline); + +#endif // SIREN_QuarkDISFromSpline_H diff --git a/projects/math/private/Vector3D.cxx b/projects/math/private/Vector3D.cxx index a820eb7c7..9bbfdddc7 100644 --- a/projects/math/private/Vector3D.cxx +++ b/projects/math/private/Vector3D.cxx @@ -265,6 +265,9 @@ double Vector3D::magnitude() const void Vector3D::normalize() { double length = std::sqrt(cartesian_.x_ * cartesian_.x_ + cartesian_.y_ * cartesian_.y_ + cartesian_.z_ * cartesian_.z_); + if(length == 0.0) { + return; + } cartesian_.x_ = cartesian_.x_ / length; cartesian_.y_ = cartesian_.y_ / length; cartesian_.z_ = cartesian_.z_ / length; diff --git a/projects/math/private/test/Vector3D_TEST.cxx b/projects/math/private/test/Vector3D_TEST.cxx index b0abc7da7..0e8c0b394 100644 --- a/projects/math/private/test/Vector3D_TEST.cxx +++ b/projects/math/private/test/Vector3D_TEST.cxx @@ -218,6 +218,61 @@ TEST(Normalize, Operator) EXPECT_TRUE(B != C); } +// Normalizing the zero vector must not divide by zero: stay finite and leave the +// vector unchanged at magnitude 0 (the contract degenerate DetectorModel p1 - p0 +// directions rely on). +TEST(Normalize, ZeroVectorDoesNotProduceNaN) +{ + Vector3D Z; + Z.SetCartesianCoordinates(0.0, 0.0, 0.0); + // Must not abort and must not produce NaN/Inf. + EXPECT_NO_THROW(Z.normalize()); + std::array z = Z; + EXPECT_FALSE(std::isnan(z[0])); + EXPECT_FALSE(std::isnan(z[1])); + EXPECT_FALSE(std::isnan(z[2])); + EXPECT_TRUE(std::isfinite(z[0])); + EXPECT_TRUE(std::isfinite(z[1])); + EXPECT_TRUE(std::isfinite(z[2])); + // The zero vector is left unchanged (magnitude stays exactly 0). + EXPECT_DOUBLE_EQ(0.0, z[0]); + EXPECT_DOUBLE_EQ(0.0, z[1]); + EXPECT_DOUBLE_EQ(0.0, z[2]); + EXPECT_DOUBLE_EQ(0.0, Z.magnitude()); +} + +// normalized() is the const sibling and must also be NaN-safe on a zero vector. +TEST(Normalize, ZeroVectorNormalizedIsFinite) +{ + Vector3D Z; + Z.SetCartesianCoordinates(0.0, 0.0, 0.0); + Vector3D N = Z.normalized(); + std::array n = N; + EXPECT_FALSE(std::isnan(n[0]) || std::isnan(n[1]) || std::isnan(n[2])); + EXPECT_DOUBLE_EQ(0.0, N.magnitude()); +} + +// Boundary just above the zero-length guard: a tiny but nonzero difference of two +// Earth-scale coordinates must normalize to a true unit vector, not be left as-is. +TEST(Normalize, TinyEarthScaleDifferenceIsFiniteUnit) +{ + // 1e-7 m is exactly representable relative to 6.371e6 m, so the subtraction is + // exact, the magnitude nonzero, and normalize() must yield a unit vector. + Vector3D p0; + p0.SetCartesianCoordinates(6.371e6, 0.0, 0.0); + Vector3D p1; + p1.SetCartesianCoordinates(6.371e6 + 1e-7, 0.0, 0.0); + Vector3D direction = p1 - p0; + double mag = direction.magnitude(); + ASSERT_GT(mag, 0.0); + direction.normalize(); + std::array d = direction; + EXPECT_FALSE(std::isnan(d[0]) || std::isnan(d[1]) || std::isnan(d[2])); + // Resulting vector is unit length. + EXPECT_NEAR(1.0, direction.magnitude(), 1e-12); + EXPECT_NEAR(1.0, d[0], 1e-12); +} + TEST(CalculateSphericalCoordinates, Conversion) { Vector3D A; diff --git a/projects/utilities/private/Random.cxx b/projects/utilities/private/Random.cxx index 214d2d12a..0d268a410 100644 --- a/projects/utilities/private/Random.cxx +++ b/projects/utilities/private/Random.cxx @@ -3,7 +3,7 @@ #include #include #include -#include // for uint32_t +#include // for uint64_t #include #include @@ -15,15 +15,15 @@ namespace siren { namespace utilities { SIREN_random::SIREN_random() { - // default to boring seed + // default to a generated seed (hashed from time/pid/host) seed = generate_seed(); - configuration = std::default_random_engine(seed); + configuration = std::mt19937_64(seed); generator = std::uniform_real_distribution( 0.0, 1.0); } SIREN_random::SIREN_random(uint64_t _seed) { seed = _seed; - configuration = std::default_random_engine(seed); + configuration = std::mt19937_64(seed); generator = std::uniform_real_distribution( 0.0, 1.0); } @@ -50,7 +50,7 @@ namespace utilities { // reconfigures the generator with a new seed void SIREN_random::set_seed(uint64_t new_seed) { seed = new_seed; - this->configuration = std::default_random_engine(seed); + this->configuration = std::mt19937_64(seed); } uint64_t SIREN_random::get_seed() const { diff --git a/projects/utilities/public/SIREN/utilities/Constants.h b/projects/utilities/public/SIREN/utilities/Constants.h index d400f01f8..6ced7d581 100644 --- a/projects/utilities/public/SIREN/utilities/Constants.h +++ b/projects/utilities/public/SIREN/utilities/Constants.h @@ -57,17 +57,12 @@ static const double lambda0Mass = 1.1156836; // GeV static const double Pi0Mass = 0.1349770; // GeV static const double PiPlusMass = 0.13957039; // GeV static const double PiMinusMass = 0.13957039; // GeV -static const double K0Mass = 0.497614; // GeV +static const double K0Mass = 0.497614; // GeV (PDG K0L) static const double KPlusMass = 0.493677; // GeV static const double KMinusMass = 0.493677; // GeV static const double KPrime0Mass = 0.896; // GeV static const double KPrimePlusMass = 0.89167; // GeV static const double KPrimeMinusMass = 0.89167; // GeV -static const double D0Mass = 1.86484; // GeV -static const double DPlusMass = 1.86962; // GeV -static const double DMinusMass = 1.86962; // GeV -static const double DsPlusMass = 1.96835; // GeV -static const double DsMinusMass = 1.96835; // GeV static const double EtaMass = 0.547862; // GeV static const double EtaPrimeMass = 0.95778; // GeV static const double Rho0Mass = 0.77526; // GeV @@ -75,6 +70,11 @@ static const double RhoPlusMass = 0.77511; // GeV static const double RhoMinusMass = 0.77511; // GeV static const double OmegaMass = 0.78266; // GeV static const double PhiMass = 1.019461; // GeV +static const double D0Mass = 1.86484; // GeV (PDG D0) +static const double DPlusMass = 1.86966; // GeV (PDG D+, 2024 PDG) +static const double DMinusMass = 1.86966; // GeV (PDG D-, 2024 PDG) +static const double DsPlusMass = 1.96835; // GeV +static const double DsMinusMass = 1.96835; // GeV static const double BPlusMass = 5.27932; // GeV static const double BMinusMass = 5.27932; // GeV // Quark masses from https://pdg.lbl.gov/2020/reviews/rpp2020-rev-quark-masses.pdf @@ -135,6 +135,9 @@ static const double fineStructure = 1.0/137.0; // dimensionless static const double hbarc = 197.3*(1e-9)*(1e-7)*GeV*cm; // [GeV m] static const double gweak = 0.64; // Pg 588 of Schwartz +//hbar +static const double hbar = 6.58211957 * (1e-25); // GeV seconds + // CKM matrix elements // from https://pdg.lbl.gov/2020/reviews/rpp2020-rev-ckm-matrix.pdf // global fit in eq 12.27 diff --git a/projects/utilities/public/SIREN/utilities/Random.h b/projects/utilities/public/SIREN/utilities/Random.h index 599e23146..e33d4599b 100644 --- a/projects/utilities/public/SIREN/utilities/Random.h +++ b/projects/utilities/public/SIREN/utilities/Random.h @@ -7,8 +7,8 @@ // this implements a class to sample numbers just like in an i3 service -#include // default_random_engine, uniform_real_distribution -#include // for uint32_t +#include // mt19937_64, uniform_real_distribution +#include // for uint64_t #include #include @@ -59,7 +59,12 @@ class SIREN_random{ private: uint64_t seed; - std::default_random_engine configuration; + // 64-bit Mersenne Twister (period 2^19937-1). The period must far exceed + // (number of seeds) x (draws per event) x (events per seed) so that distinct + // seeds never collide into overlapping draw sequences. A given seed + // deterministically fixes the entire sequence; only the seed is serialized + // (version 0), so any cached samples or golden baselines are keyed to the seed. + std::mt19937_64 configuration; std::uniform_real_distribution generator; }; @@ -67,4 +72,3 @@ class SIREN_random{ } // namespace siren #endif // SIREN_Random_H - diff --git a/python/SIREN_Controller.py b/python/SIREN_Controller.py index d9948d44c..146257d74 100644 --- a/python/SIREN_Controller.py +++ b/python/SIREN_Controller.py @@ -116,39 +116,32 @@ def SetInjectionProcesses( # Define the primary injection process primary type self.primary_injection_process.primary_type = primary_type - # Default injection distributions + # Default injection distributions. The pybind Process exposes a + # `distributions` list property (the old Add*Distribution methods were + # removed upstream), so assemble the full list and assign it once. + primary_idist_list = [] if "mass" not in primary_injection_distributions.keys(): - self.primary_injection_process.AddPrimaryInjectionDistribution( - _distributions.PrimaryMass(0) - ) - + primary_idist_list.append(_distributions.PrimaryMass(0)) if "helicity" not in primary_injection_distributions.keys(): - self.primary_injection_process.AddPrimaryInjectionDistribution( - _distributions.PrimaryNeutrinoHelicityDistribution() - ) + primary_idist_list.append(_distributions.PrimaryNeutrinoHelicityDistribution()) # Add all injection distributions for _, idist in primary_injection_distributions.items(): - self.primary_injection_process.AddPrimaryInjectionDistribution(idist) + primary_idist_list.append(idist) + self.primary_injection_process.distributions = primary_idist_list # Loop through possible secondary interactions for i_sec, secondary_type in enumerate(secondary_types): secondary_injection_process = _injection.SecondaryInjectionProcess() secondary_injection_process.primary_type = secondary_type - # Add all injection distributions - for idist in secondary_injection_distributions[i_sec]: - secondary_injection_process.AddSecondaryInjectionDistribution(idist) - + sec_idist_list = list(secondary_injection_distributions[i_sec]) # Add the position distribution if fid_vol_secondary and self.fid_vol is not None: - secondary_injection_process.AddSecondaryInjectionDistribution( - _distributions.SecondaryBoundedVertexDistribution(self.fid_vol) - ) + sec_idist_list.append(_distributions.SecondaryBoundedVertexDistribution(self.fid_vol)) else: - secondary_injection_process.AddSecondaryInjectionDistribution( - _distributions.SecondaryPhysicalVertexDistribution() - ) + sec_idist_list.append(_distributions.SecondaryPhysicalVertexDistribution()) + secondary_injection_process.distributions = sec_idist_list self.secondary_injection_processes.append(secondary_injection_process) @@ -170,30 +163,24 @@ def SetPhysicalProcesses( # Define the primary physical process primary type self.primary_physical_process.primary_type = primary_type - # Default physical distributions + # Default physical distributions (assign the `distributions` list, as for + # the injection processes above). + primary_pdist_list = [] if "mass" not in primary_physical_distributions.keys(): - self.primary_physical_process.AddPhysicalDistribution( - _distributions.PrimaryMass(0) - ) - + primary_pdist_list.append(_distributions.PrimaryMass(0)) if "helicity" not in primary_physical_distributions.keys(): - self.primary_physical_process.AddPhysicalDistribution( - _distributions.PrimaryNeutrinoHelicityDistribution() - ) + primary_pdist_list.append(_distributions.PrimaryNeutrinoHelicityDistribution()) # Add all physical distributions for _, pdist in primary_physical_distributions.items(): - self.primary_physical_process.AddPhysicalDistribution(pdist) + primary_pdist_list.append(pdist) + self.primary_physical_process.distributions = primary_pdist_list # Loop through possible secondary interactions for i_sec, secondary_type in enumerate(secondary_types): secondary_physical_process = _injection.PhysicalProcess() secondary_physical_process.primary_type = secondary_type - - # Add all physical distributions - for pdist in secondary_physical_distributions[i_sec]: - secondary_physical_process.AddPhysicalDistribution(pdist) - + secondary_physical_process.distributions = list(secondary_physical_distributions[i_sec]) self.secondary_physical_processes.append(secondary_physical_process) def SetProcesses( @@ -295,15 +282,14 @@ def InputDarkNewsModel(self, primary_type, table_dir, upscattering=True, decay=T secondary_injection_process = _injection.SecondaryInjectionProcess() secondary_injection_process.primary_type = secondary_type - # Add the secondary position distribution + # Add the secondary position distribution (append to whatever the + # process already carries; the pybind `distributions` is a list property). + sec_dists = list(secondary_injection_process.distributions) if fid_vol_secondary and self.fid_vol is not None: - secondary_injection_process.AddSecondaryInjectionDistribution( - _distributions.SecondaryBoundedVertexDistribution(self.fid_vol) - ) + sec_dists.append(_distributions.SecondaryBoundedVertexDistribution(self.fid_vol)) else: - secondary_injection_process.AddSecondaryInjectionDistribution( - _distributions.SecondaryPhysicalVertexDistribution() - ) + sec_dists.append(_distributions.SecondaryPhysicalVertexDistribution()) + secondary_injection_process.distributions = sec_dists if not inj_sec_defined: self.secondary_injection_processes.append(secondary_injection_process) @@ -510,7 +496,7 @@ def InitializeInjector(self, filenames=None): assert(self.primary_injection_process.primary_type is not None) # Use controller injection objects self.injectors.append( - _injection.Injector( + _injection._Injector( self.events_to_inject, self.detector_model, self.primary_injection_process, @@ -523,7 +509,7 @@ def InitializeInjector(self, filenames=None): assert(len(filenames)>0) # require at least one injector filename for filename in filenames: self.injectors.append( - _injection.Injector( + _injection._Injector( self.events_to_inject, filename, self.random, @@ -537,7 +523,7 @@ def InitializeWeighter(self,filename=None): if filename is None: assert(self.primary_physical_process.primary_type is not None) # Use controller physical objects - self.weighter = _injection.Weighter( + self.weighter = _injection._Weighter( self.injectors, self.detector_model, self.primary_physical_process, @@ -545,7 +531,7 @@ def InitializeWeighter(self,filename=None): ) else: # Try initilalizing with the provided filename - self.weighter = _injection.Weighter( + self.weighter = _injection._Weighter( self.injectors, filename ) @@ -689,10 +675,14 @@ def SaveEvents(self, filename, fill_tables_at_exit=True, datasets["num_secondaries"][-1].append(isec+1) datasets["num_interactions"].append(id+1) - # save injector and weighter - self.injector.SaveInjector(filename) - # weighter saving not yet supported - #self.weighter.SaveWeighter(filename) + # save injector and weighter (writes .siren_injector and + # .siren_weighter alongside the event file). These are the + # pybind _Injector/_Weighter objects, so use their C++ serialization + # methods (SaveInjector writes the literal path; SaveWeighter appends + # the .siren_weighter suffix). The weighter is optional. + self.injector.SaveInjector(filename + ".siren_injector") + if hasattr(self, "weighter"): + self.weighter.SaveWeighter(filename) # save events ak_array = ak.Array(datasets) diff --git a/python/Weighter.py b/python/Weighter.py index 69f8c4280..35264098a 100644 --- a/python/Weighter.py +++ b/python/Weighter.py @@ -308,3 +308,36 @@ def event_weight(self, interaction_tree: InteractionTree) -> float: float: The calculated event weight. """ return self(interaction_tree) + + def save(self, filename: str): + """ + Serialize the weighter to ``.siren_weighter``. + + Args: + filename: Base path; the ".siren_weighter" suffix is added. + """ + if self.__weighter is None: + self.__initialize_weighter() + self.__weighter.SaveWeighter(filename) + + def load(self, filename: str): + """ + Restore the weighter from ``.siren_weighter``. + + Constructs the underlying C++ weighter via its ``(injectors, filename)`` + constructor, which loads the detector model and physical processes from + the file. The detector / interactions / distributions therefore do NOT + need to be configured first (unlike a freshly built weighter). Only the + injectors are used -- to bind the weighter to your live injection + processes for the generation-probability cancellation; if they were not + set, the injectors serialized in the file are used instead. + + Args: + filename: Base path; the ".siren_weighter" suffix is added. + """ + if self.__injectors is not None: + injectors = [injector._Injector__injector if isinstance(injector, _PyInjector) else injector + for injector in self.__injectors] + else: + injectors = [] + self.__weighter = _Weighter(injectors, filename) diff --git a/python/__init__.py b/python/__init__.py index e59cd934b..e161825b8 100644 --- a/python/__init__.py +++ b/python/__init__.py @@ -49,9 +49,16 @@ def darknews_version(): try: import DarkNews + # Require the specific DarkNews APIs that SIREN_DarkNews.py uses. + # Some installed DarkNews versions ship without these, in which case + # we treat DarkNews as unavailable so the SIREN_DarkNews import path + # is skipped (same as when DarkNews itself is not installed). + from DarkNews import phase_space # noqa: F401 + from DarkNews.nuclear_tools import NuclearTarget # noqa: F401 + from DarkNews.integrands import get_decay_momenta_from_vegas_samples # noqa: F401 return _util.normalize_version(DarkNews.__version__) - except: - print("WARNING: DarkNews is not installed in the local environment") + except Exception: + print("WARNING: DarkNews is not available (not installed, or installed version lacks required APIs)") return None utilities.darknews_version = darknews_version diff --git a/python/pythia_charm_splines.py b/python/pythia_charm_splines.py new file mode 100644 index 000000000..1d1a9a8d5 --- /dev/null +++ b/python/pythia_charm_splines.py @@ -0,0 +1,108 @@ +"""Generate Pythia-derived charm-DIS splines for ``PythiaDISCrossSection``. + +``PythiaDISCrossSection`` samples the final state from Pythia and uses splines for +the cross-section *rate* (and, optionally, a differential density). These helpers +build those splines directly from Pythia so they correspond to exactly the events +the sampler produces: + + * :func:`generate_total_spline` -> 1D ``sigma(E)`` FITS (always required). + * :func:`generate_differential_spline` -> 3D ``d2sigma/dx dy`` FITS (optional; + supplying it gives ``FinalStateProbability`` a real density so weights stay + correct under reweighting; omitting it makes ``FinalStateProbability`` a + constant that cancels in the unbiased weight). + +Both run Pythia via ``PythiaDISCrossSection.GeneratePythiaCharmSamples`` and fit +with photospline. ``LHAPDF_DATA_PATH`` must be set in the environment, and SIREN +must be built with ``SIREN_WITH_PYTHIA8=ON``. + +The differential is fit in the muon-reconstructed Bjorken ``x`` (the same variable +``DifferentialCrossSection`` reconstructs), so it closes against the sampler. +""" +import numpy as np + + +def _knots(c, order=2): + c = np.asarray(c, float) + d = c[1] - c[0] + return np.concatenate([c[0] - d * np.arange(order, 0, -1), c, + c[-1] + d * np.arange(1, order + 1)]) + + +def _run_pythia(interaction_type, primary_pdg, target_pdg, target_mass, pdf_set, + pythia_data_path, minimum_Q2, energies, n_events): + import siren.interactions + sigma_mb, E, x, y = siren.interactions.PythiaDISCrossSection.GeneratePythiaCharmSamples( + int(interaction_type), int(primary_pdg), int(target_pdg), float(target_mass), + str(pdf_set), str(pythia_data_path), float(minimum_Q2), + [float(e) for e in energies], int(n_events)) + return np.asarray(sigma_mb), np.asarray(E), np.asarray(x), np.asarray(y) + + +def generate_total_spline(out_path, *, interaction_type, primary_pdg, target_pdg, + target_mass, pdf_set, pythia_data_path, energies, + minimum_Q2=1.0, n_events=20000, mb_to_cm2=1e-27): + """Fit and write the 1D total cross-section spline ``log10(sigma_cm2)`` vs ``log10(E)``.""" + import photospline + sigma_mb, _, _, _ = _run_pythia(interaction_type, primary_pdg, target_pdg, + target_mass, pdf_set, pythia_data_path, + minimum_Q2, energies, n_events) + logE = np.log10(np.asarray(energies, float)) + logsig = np.log10(sigma_mb * mb_to_cm2) + z, w = photospline.ndsparse.from_data(logsig, np.ones_like(logsig)) + spline = photospline.glam_fit(z, w, [logE], [_knots(logE)], [2], [1e-3], [2]) + spline.write(out_path) + return out_path + + +def generate_differential_spline(out_path, *, interaction_type, primary_pdg, target_pdg, + target_mass, pdf_set, pythia_data_path, energies, + minimum_Q2=1.0, n_events=50000, + logx_range=(-4.0, -0.1), logy_range=(-2.3, -0.004), + nbins=12, mb_to_cm2=1e-27): + """Fit and write the 3D differential spline ``log10(d2sigma/dx dy)`` vs ``(log10 E, log10 x, log10 y)``. + + The per-bin density is ``sigma(E) * dN/dx dy / N`` (log-space Jacobian folded + in) so that ``FinalStateProbability = dsigma/sigma`` reproduces the sampled + ``(x, y)`` density. + """ + import photospline + sigma_mb, E, x, y = _run_pythia(interaction_type, primary_pdg, target_pdg, + target_mass, pdf_set, pythia_data_path, + minimum_Q2, energies, n_events) + energies = np.asarray(energies, float) + logE_grid = np.log10(energies) + logx_edges = np.linspace(logx_range[0], logx_range[1], nbins + 1) + logy_edges = np.linspace(logy_range[0], logy_range[1], nbins + 1) + logx_c = 0.5 * (logx_edges[:-1] + logx_edges[1:]) + logy_c = 0.5 * (logy_edges[:-1] + logy_edges[1:]) + dlx = logx_edges[1] - logx_edges[0] + dly = logy_edges[1] - logy_edges[0] + ln10 = np.log(10.0) + nE, nx, ny = len(energies), len(logx_c), len(logy_c) + Z = np.full((nE, nx, ny), -99.0) + W = np.zeros((nE, nx, ny)) + for ie, Eval in enumerate(energies): + sigma = sigma_mb[ie] * mb_to_cm2 + m = (E == Eval) + Ntot = int(m.sum()) + if Ntot == 0: + continue + H, _, _ = np.histogram2d(np.log10(x[m]), np.log10(y[m]), + bins=[logx_edges, logy_edges]) + for ix in range(nx): + xc = 10.0 ** logx_c[ix] + for iy in range(ny): + c = H[ix, iy] + if c <= 0: + continue + yc = 10.0 ** logy_c[iy] + dxdy = (xc * ln10 * dlx) * (yc * ln10 * dly) + Z[ie, ix, iy] = np.log10(sigma * c / (Ntot * dxdy)) + W[ie, ix, iy] = c + z, w = photospline.ndsparse.from_data(Z, W) + spline = photospline.glam_fit( + z, w, [logE_grid, logx_c, logy_c], + [_knots(logE_grid), _knots(logx_c), _knots(logy_c)], + [2, 2, 2], [5e-2, 5e-2, 5e-2], [2, 2, 2]) + spline.write(out_path) + return out_path diff --git a/resources/examples/example1/DIS_IceCube_charm.py b/resources/examples/example1/DIS_IceCube_charm.py new file mode 100644 index 000000000..1b28844ae --- /dev/null +++ b/resources/examples/example1/DIS_IceCube_charm.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +""" +Charm-DIS example using the upstream PR #71 Injector/Weighter idiom. +No SIREN_Controller. + +Demonstrates the full modern charm chain: + primary NuE CC+NC DIS (QuarkDISFromSpline, charm-target splines) + emits {charged lepton, Hadrons (shower), D meson} directly -- no + intermediate "Charm" quark, no separate hadronization step. + For a neutrino primary QuarkDISFromSpline::DTypesForPrimary emits + {D0, D+, Ds+} (the charge conjugates {D0bar, D-, Ds-} are emitted for + an antineutrino primary). + secondary on each D meson + DMesonELoss (propagation energy loss) + CharmMesonDecay (decay into leptons + K/pi) + +10,000 events on IceCube, seed=1, volume injection inside the icecube sector, +astrophysical power-law weighting. + +Splines are read from the SIREN_CHARM_SPLINE_DIR environment variable; point it +at your own set of QuarkDIS charm-target spline files before running. + +Usage: + export SIREN_CHARM_SPLINE_DIR=/path/to/M_Muon_New + python3 DIS_IceCube_charm.py +""" + +import os +import numpy as np + +import siren +from siren._util import GenerateEvents, SaveEvents + + +# ---------------------------------------------------------------------------- +# Config (edit for your setup) +# ---------------------------------------------------------------------------- + +# Spline directory: read from the SIREN_CHARM_SPLINE_DIR environment variable. +# These QuarkDIS charm-target .fits splines are large and machine-specific, so +# they are not bundled with SIREN. Point the variable at your own spline set, +# e.g. export SIREN_CHARM_SPLINE_DIR=/path/to/M_Muon_New +SPLINES_DIR = os.environ.get("SIREN_CHARM_SPLINE_DIR") +if not SPLINES_DIR: + raise RuntimeError( + "SIREN_CHARM_SPLINE_DIR is not set. Set it to the directory containing " + "the QuarkDIS charm-target spline files " + "(dsdxidy_nu-N-{cc,nc}-charm-*.fits and sigma_nu-N-{cc,nc}-charm-*.fits) " + "before running this example." + ) +EXPERIMENT = "IceCube" +PRIMARY_TYPE = siren.dataclasses.ParticleType.NuE +NUMBER_OF_EVENTS = 10_000 +SEED = 1 +GEN_EMIN, GEN_EMAX = 1e2, 1e6 # generation energy range [GeV] +OXYGEN_PDF = "EPPS21nlo_CT18Anlo_O16_central" +HYDROGEN_PDF = "HERAPDF20_NLO_EIG_central" +OUTPUT_PREFIX = "output/charm_example" + + +# ---------------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------------- + +def cylinder_volume_position_distribution(detector_model, sector_name): + """Build a CylinderVolumePositionDistribution for a named detector sector. + + Reproduces `SIREN_Controller.GetCylinderVolumePositionDistributionFromSector` + without the Controller. Mirrors the inline pattern used in upstream + `resources/examples/example1/DIS_ATLAS.py`. + """ + geo = None + for sector in detector_model.Sectors: + if sector.name == sector_name: + geo = sector.geo + break + if geo is None: + raise ValueError(f"Detector sector {sector_name!r} not found") + det_position = detector_model.GeoPositionToDetPosition( + siren.detector.GeometryPosition(geo.placement.Position) + ) + det_rotation = geo.placement.Quaternion + det_placement = siren.geometry.Placement(det_position.get(), det_rotation) + cylinder = siren.geometry.Cylinder( + det_placement, geo.Radius, geo.InnerRadius, geo.Z + ) + return siren.distributions.CylinderVolumePositionDistribution(cylinder) + + +def make_quark_dis_xs(pdf, target, current_type): + """Build one QuarkDISFromSpline for a given PDF / nuclear target / CC or NC.""" + int_type = 1 if current_type == "cc" else 2 + isoscalar_mass = (0.938272 + 0.939565) / 2 + return siren.interactions.QuarkDISFromSpline( + os.path.join(SPLINES_DIR, f"dsdxidy_nu-N-{current_type}-charm-{pdf}.fits"), + os.path.join(SPLINES_DIR, f"sigma_nu-N-{current_type}-charm-{pdf}.fits"), + int(int_type), # interaction type: 1=CC, 2=NC + isoscalar_mass, + 1, # min Q^2 + [PRIMARY_TYPE], + [target], + "m", # mass units + ) + + +# ---------------------------------------------------------------------------- +# Primary interactions +# ---------------------------------------------------------------------------- + +PT = siren.dataclasses.ParticleType + +detector_model = siren.utilities.load_detector(EXPERIMENT) + +primary_interactions = [ + make_quark_dis_xs(OXYGEN_PDF, PT.O16Nucleus, "cc"), + make_quark_dis_xs(HYDROGEN_PDF, PT.HNucleus, "cc"), + make_quark_dis_xs(OXYGEN_PDF, PT.O16Nucleus, "nc"), + make_quark_dis_xs(HYDROGEN_PDF, PT.HNucleus, "nc"), +] + +# Generation energy spectrum: flat power law with index 1 over [emin, emax] +# Astrophysical reweight: E^-2.58 normalized to the HESE flux at 100 TeV +edist_gen = siren.distributions.PowerLaw(1, GEN_EMIN, GEN_EMAX) +edist_phy = siren.distributions.PowerLaw(2.58, 1e2, 1e6) +edist_phy.SetNormalizationAtEnergy(1.68e-18 * 1e4 * 4 * np.pi, 1e5) + +direction = siren.distributions.IsotropicDirection() +position = cylinder_volume_position_distribution(detector_model, "icecube") + +primary_injection_distributions = [ + siren.distributions.PrimaryMass(0), + siren.distributions.PrimaryNeutrinoHelicityDistribution(), + edist_gen, + direction, + position, +] +primary_physical_distributions = [ + siren.distributions.PrimaryMass(0), + siren.distributions.PrimaryNeutrinoHelicityDistribution(), + edist_phy, + direction, +] + + +# ---------------------------------------------------------------------------- +# Secondary interactions (one entry per emitted D species) +# +# QuarkDISFromSpline emits a D meson directly as one of the primary-DIS +# secondaries (not a bare charm quark), so no CharmHadronization step is +# needed. We attach energy-loss and decay to each D species. +# +# IMPORTANT: every D type that QuarkDISFromSpline emits MUST have an entry here. +# The injector silently DROPS any emitted secondary that has no registered +# process (Injector.cxx: `if(it == secondary_process_map.end()) continue;`), so +# an unregistered species would be generated by the primary DIS and then never +# decay or lose energy -- a dangling secondary that distorts the event sample. +# +# For the NuE (neutrino) primary used here, DTypesForPrimary emits {D0, D+, Ds+}. +# (An antineutrino primary would instead emit {D0bar, D-, Ds-}; this example +# would then need D_TYPES updated to the conjugates.) We build the secondary +# dicts from D_TYPES so the registered set cannot silently desync from the +# emitter. DMesonELoss() handles all D species; the 2-body CharmMesonDecay +# supports D0/D+/Ds+ (and their conjugates). +# ---------------------------------------------------------------------------- + +# Must match QuarkDISFromSpline::DTypesForPrimary for a neutrino primary. +D_TYPES = [PT.D0, PT.DPlus, PT.DsPlus] + +secondary_interactions = { + d: [ + siren.interactions.DMesonELoss(), + siren.interactions.CharmMesonDecay(primary_type=d), + ] + for d in D_TYPES +} + +sec_vertex_dist = [siren.distributions.SecondaryPhysicalVertexDistribution()] +secondary_injection_distributions = {d: sec_vertex_dist for d in D_TYPES} +secondary_physical_distributions = {d: [] for d in D_TYPES} + + +# ---------------------------------------------------------------------------- +# Injector -- new idiom, property-setter API +# ---------------------------------------------------------------------------- + +injector = siren.injection.Injector() +injector.seed = SEED +injector.number_of_events = NUMBER_OF_EVENTS +injector.detector_model = detector_model +injector.primary_type = PRIMARY_TYPE +injector.primary_interactions = primary_interactions +injector.primary_injection_distributions = primary_injection_distributions +injector.secondary_interactions = secondary_interactions +injector.secondary_injection_distributions = secondary_injection_distributions +injector.stopping_condition = lambda datum, i: False + +events, gen_times = GenerateEvents(injector) +print(f"Generated {len(events)} events") + + +# ---------------------------------------------------------------------------- +# Weighter -- new idiom, built after the injector is materialised +# ---------------------------------------------------------------------------- + +weighter = siren.injection.Weighter() +weighter.injectors = [injector] +weighter.detector_model = detector_model +weighter.primary_type = PRIMARY_TYPE +weighter.primary_interactions = list(primary_interactions) +weighter.primary_physical_distributions = primary_physical_distributions +weighter.secondary_interactions = secondary_interactions +weighter.secondary_physical_distributions = secondary_physical_distributions + + +# ---------------------------------------------------------------------------- +# Save events (.siren_events + .hdf5 + .parquet) +# ---------------------------------------------------------------------------- + +os.makedirs(os.path.dirname(OUTPUT_PREFIX), exist_ok=True) +fid_vol = siren.utilities.get_fiducial_volume(EXPERIMENT) +SaveEvents(events, weighter, gen_times, fid_vol=fid_vol, output_filename=OUTPUT_PREFIX) +print(f"Saved output to {OUTPUT_PREFIX}.*") diff --git a/resources/examples/example1/README_charm.md b/resources/examples/example1/README_charm.md new file mode 100644 index 000000000..b588639bf --- /dev/null +++ b/resources/examples/example1/README_charm.md @@ -0,0 +1,103 @@ +# Charm-DIS production in SIREN + +SIREN can produce charmed hadrons from neutrino deep-inelastic scattering and +decay them semileptonically -- the production chain behind the IceCube diffuse +multi-cascade tau/charm search. There are two interchangeable production paths; +both emit a `D` meson directly as a DIS secondary (no separate hadronization +vertex), which a `CharmMesonDecay` secondary process then decays. + +## Two production paths + +| | `PythiaDISCrossSection` | `QuarkDISFromSpline` | +|---|---|---| +| Final state | sampled live from **Pythia8** | analytic slow-rescaling `(xi, y)` | +| Rate | total `sigma(E)` spline (**required**) | total `sigma` spline | +| Differential | **optional** (`d2sigma/dx dy` spline) | required (`dsdxidy` spline) | +| Reweightable | unbiased-only by default; reweightable if a differential spline is supplied | fully reweightable | +| Current | **charged-current only** | CC and NC | +| Use it for | matching Pythia's full hadronization exactly | a fast, fully analytic, reweightable generator | + +`PythiaDISCrossSection` is "Pythia as the integrated generator": `SampleFinalState` +is pure Pythia, and the splines are only the rate/weighting side. When no +differential spline is supplied, `FinalStateProbability` returns a constant that +cancels in the unbiased weight (only the total cross section matters); supply a +differential spline to get a real `dsigma/sigma` density for reweighting. + +## Runtime requirements (`PythiaDISCrossSection`) + +`SampleFinalState` needs Pythia8 + LHAPDF at runtime. The total-cross-section / +weighting path does **not** (it only reads the splines). + +1. Build SIREN with `-DSIREN_WITH_PYTHIA8=ON`. +2. `export LHAPDF_DATA_PATH=` -- the parent of the installed PDF-set folders. + The requested `pdf_set` (default `LHAPDF6:HERAPDF20_NLO_EIG`) **must be + installed there**, or construction raises a clear error naming the set. Any + installed LHAPDF set works (e.g. `LHAPDF6:CT18NLO`). +3. `export PYTHIA8DATA=/share/Pythia8/xmldoc`. +4. macOS: `export DYLD_LIBRARY_PATH=$PREFIX/lib:/lib:$DYLD_LIBRARY_PATH`. + +NC (`interaction_type=2`) is rejected at construction: Z exchange does not force +charm in Pythia. Use `QuarkDISFromSpline` for NC charm. + +## Generating the Pythia splines + +The Pythia splines are not committed (they depend on the PDF set and Pythia +version). Regenerate them from the same Pythia config the sampler uses: + +```bash +python generate_charm_pythia_splines.py --out ./splines \ + --pdf LHAPDF6:CT18NLO --emin 100 --emax 1e6 --npoints 17 +``` + +This writes `pythia_charm_sigma.fits` (always) and `pythia_charm_dsdxdy.fits` +(unless `--no-differential`). The default grid (100 GeV - 1 PeV) covers the +diffuse analysis band. See `siren.pythia_charm_splines` for the library API. + +The slow-rescaling `QuarkDISFromSpline` `dsdxidy_/sigma_nu-N-{cc,nc}-charm-*.fits` +splines are the analysis's external inputs (Dutta-Kim CT14 tooling). For a +physically-validated *reference* set from any installed LHAPDF set, build and run +`generate_charm_slowrescaling_splines.cpp` (LO slow rescaling: PDFs at the parton +fraction xi, `Q^2 = 2 M E y xi - m_c^2`) then `fit_charm_slowrescaling_splines.py`. +The integrated charm fraction is ~4% at 100 GeV rising to ~6% at 10 TeV -- the +literature band, cross-checking the Pythia charm fraction ~6.5%. Point +`SIREN_CHARM_SPLINE_DIR` at either set to run `DIS_IceCube_charm.py` and +`test_quarkdis_slow_rescaling.py` (kinematic bounds, sample==density closure, and +the charm-fraction normalization). + +Out-of-range events raise: if a sampled event falls outside the differential +spline's `(E, x/xi, y)` support, `DifferentialCrossSection` raises (it never +silently returns 0, which would bias that event's weight). Regenerate the spline +wider, or check why the event is out of range. + +## Validating against the Pythia-vs-pythiaSIREN slides + +`reproduce_dimuon_kinematics.py` runs the full chain (PythiaDIS DIS -> D -> +`CharmMesonDecay` muon channel) at E_nu = 100 GeV and saves the five slide +observables (mu-pair opening angle, semileptonic E_mu/E_D, Bjorken-y, D/mu opening +angle, Q^2). They reproduce the slide histogram shapes; the committed regression +`tests/python/test_pythia_charm_validation.py` checks SampleFinalState against bare +Pythia quantitatively. + +## Charmed-hadron decay + +`CharmMesonDecay` (the 2-body class used by the chain) handles `D0`, `D+`, `Ds` +and their anti-flavors: `D+/-` and `D0/D0bar` via `K`/`K*(892)` semileptonic +modes with a V-A matrix element, and `Ds` via `eta/eta'/phi` pure phase space. +`CharmMesonDecay3Body` is an alternate D0/D+-only implementation (it rejects +other species at construction). `Decay.TotalDecayLength(record)` returns the lab +decay length `beta*gamma*c*tau` **in meters** -- the cascade separation the +Taupede reconstruction targets. + +Every D species a production cross section emits **must** have a registered +secondary decay process, or the injector silently drops it (see the comment in +`DIS_IceCube_charm.py`). The fragmentation fractions (`D0:D+/-:Ds = 0.60:0.23:0.15`, +renormalized /0.98) fold the unmodeled `Lambda_c` into the D mesons. + +## Known limitations / deferred work + +- **NC charm** is not supported by `PythiaDISCrossSection` (use `QuarkDISFromSpline`). +- **`Lambda_c`** (~0.09 raw fraction) is folded into the D mesons, not modeled + as a distinct species (it has a different lifetime and semileptonic mix). +- **Per-event Pythia re-init** (~0.1-1 s/event): variable-energy mode is + unsupported for `WeakBosonExchange`, so each event rebuilds Pythia at its beam + energy. Fine for analysis-scale samples; a throughput item for very large runs. diff --git a/resources/examples/example1/fit_charm_slowrescaling_splines.py b/resources/examples/example1/fit_charm_slowrescaling_splines.py new file mode 100644 index 000000000..059a6d3c5 --- /dev/null +++ b/resources/examples/example1/fit_charm_slowrescaling_splines.py @@ -0,0 +1,70 @@ +"""Fit the slow-rescaling charm grid (from generate_charm_slowrescaling_splines) +into the FITS splines QuarkDISFromSpline consumes. + +Reads xidiff.txt (3D log10 d2sigma/dxi dy on a log10(E), log10(xi), log10(y) grid) +and xisigma.txt (1D total sigma vs E), and writes + + dsdxidy_nu-N-cc-charm-CT14nlo_central.fits (3D differential) + sigma_nu-N-cc-charm-CT14nlo_central.fits (1D total, cm^2) + +Point SIREN_CHARM_SPLINE_DIR at the output directory to run +tests/python/test_quarkdis_slow_rescaling.py (kinematic bounds, differential +positivity, sample==density closure, and the charm-fraction normalization). +The differential stores log10(d2sigma/dxi dy) directly (QuarkDISFromSpline's +DifferentialCrossSection returns 10**value); the total stores log10(sigma_cm2). +""" +import os +import sys + +import numpy as np +import photospline + + +def knots(c, order=2): + c = np.asarray(c, float) + d = c[1] - c[0] + return np.concatenate([c[0] - d * np.arange(order, 0, -1), c, + c[-1] + d * np.arange(1, order + 1)]) + + +def main(): + src = sys.argv[1] if len(sys.argv) > 1 else "." + out = sys.argv[2] if len(sys.argv) > 2 else "." + + with open(os.path.join(src, "xidiff.txt")) as f: + nE, nxi, ny = map(int, f.readline().split()) + Ev = [float(f.readline()) for _ in range(nE)] + lxi = [float(f.readline()) for _ in range(nxi)] + ly = [float(f.readline()) for _ in range(ny)] + Z = np.full((nE, nxi, ny), -99.0) + W = np.zeros((nE, nxi, ny)) + for i in range(nE): + for j in range(nxi): + for k in range(ny): + v, w = f.readline().split() + Z[i, j, k] = float(v) + W[i, j, k] = float(w) + + logE = np.log10(Ev) + z, w = photospline.ndsparse.from_data(Z, W) + sp = photospline.glam_fit( + z, w, [logE, np.array(lxi), np.array(ly)], + [knots(logE), knots(lxi), knots(ly)], [2, 2, 2], [1e-2, 1e-2, 1e-2], [2, 2, 2]) + diff_out = os.path.join(out, "dsdxidy_nu-N-cc-charm-CT14nlo_central.fits") + sp.write(diff_out) + + E2, sig = np.loadtxt(os.path.join(src, "xisigma.txt"), unpack=True) + zt, wt = photospline.ndsparse.from_data(np.log10(sig), np.ones_like(sig)) + spt = photospline.glam_fit( + zt, wt, [np.log10(E2)], [knots(np.log10(E2))], [2], [1e-3], [2]) + tot_out = os.path.join(out, "sigma_nu-N-cc-charm-CT14nlo_central.fits") + spt.write(tot_out) + + i100 = int(np.argmin(np.abs(E2 - 100.0))) + print(f"wrote {diff_out}") + print(f"wrote {tot_out}; sigma(100 GeV)={sig[i100]:.3e} cm^2 " + f"charm_fraction={sig[i100] / (0.677e-38 * 100):.4f}") + + +if __name__ == "__main__": + main() diff --git a/resources/examples/example1/generate_charm_pythia_splines.py b/resources/examples/example1/generate_charm_pythia_splines.py new file mode 100644 index 000000000..10049af3d --- /dev/null +++ b/resources/examples/example1/generate_charm_pythia_splines.py @@ -0,0 +1,80 @@ +"""Generate the Pythia charm-DIS splines used by PythiaDISCrossSection. + +PythiaDISCrossSection samples the charm-DIS final state directly from Pythia and +uses photospline FITS tables only for the cross-section *rate* (a 1D total +sigma(E), always required) and, optionally, a 3D differential d2sigma/dx dy that +lets FinalStateProbability report a real density for reweighting. The splines +are built from the SAME Pythia configuration the sampler uses, so they close +against it. Pythia does NOT read these splines and the splines are NOT committed +to the repo (they depend on the PDF set and Pythia version) -- regenerate them +locally with this script. + +Requirements (see README_charm.md): + * SIREN built with -DSIREN_WITH_PYTHIA8=ON + * LHAPDF_DATA_PATH set, with the requested PDF set installed + * PYTHIA8DATA pointing at the Pythia xmldoc directory + * DYLD_LIBRARY_PATH (macOS) including the prefix lib + the Pythia lib + +Usage: + python generate_charm_pythia_splines.py --out ./splines \ + --pdf LHAPDF6:CT18NLO --emin 100 --emax 1e6 --npoints 17 + +The default energy grid (100 GeV - 1 PeV) covers the IceCube diffuse +multi-cascade charm analysis band. +""" +import argparse +import os + +import numpy as np + +import siren.pythia_charm_splines as pcs + +# nu / target PDG defaults (charged-current nu_mu on an isoscalar nucleon). +ISO_MASS = 0.9314943 # GeV + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--out", default=".", help="output directory") + ap.add_argument("--pdf", default="LHAPDF6:CT18NLO", help="LHAPDF set (must be installed)") + ap.add_argument("--interaction-type", type=int, default=1, help="1=CC (only CC is supported)") + ap.add_argument("--primary-pdg", type=int, default=14, help="primary neutrino PDG (e.g. 14 = nu_mu)") + ap.add_argument("--target-pdg", type=int, default=2212, help="target nucleon PDG (2212 p / 2112 n)") + ap.add_argument("--emin", type=float, default=100.0, help="min neutrino energy [GeV]") + ap.add_argument("--emax", type=float, default=1.0e6, help="max neutrino energy [GeV]") + ap.add_argument("--npoints", type=int, default=17, help="log-spaced energy points") + ap.add_argument("--minimum-q2", type=float, default=1.0, help="Pythia PhaseSpace:Q2Min [GeV^2]") + ap.add_argument("--n-total", type=int, default=4000, help="events/energy for the total spline") + ap.add_argument("--n-diff", type=int, default=20000, help="events/energy for the differential spline") + ap.add_argument("--no-differential", action="store_true", help="only build the total spline") + args = ap.parse_args() + + if not os.environ.get("LHAPDF_DATA_PATH"): + raise SystemExit("LHAPDF_DATA_PATH is not set (parent of the PDF set folders).") + pythia_data = os.environ.get("PYTHIA8DATA", "") + os.makedirs(args.out, exist_ok=True) + + energies = np.logspace(np.log10(args.emin), np.log10(args.emax), args.npoints).tolist() + print("energies (GeV):", [round(e, 1) for e in energies], flush=True) + + sigma_out = os.path.join(args.out, "pythia_charm_sigma.fits") + pcs.generate_total_spline( + sigma_out, interaction_type=args.interaction_type, primary_pdg=args.primary_pdg, + target_pdg=args.target_pdg, target_mass=ISO_MASS, pdf_set=args.pdf, + pythia_data_path=pythia_data, energies=energies, minimum_Q2=args.minimum_q2, + n_events=args.n_total) + print("wrote", sigma_out, flush=True) + + if not args.no_differential: + diff_out = os.path.join(args.out, "pythia_charm_dsdxdy.fits") + pcs.generate_differential_spline( + diff_out, interaction_type=args.interaction_type, primary_pdg=args.primary_pdg, + target_pdg=args.target_pdg, target_mass=ISO_MASS, pdf_set=args.pdf, + pythia_data_path=pythia_data, energies=energies, minimum_Q2=args.minimum_q2, + n_events=args.n_diff) + print("wrote", diff_out, flush=True) + + +if __name__ == "__main__": + main() diff --git a/resources/examples/example1/generate_charm_slowrescaling_splines.cpp b/resources/examples/example1/generate_charm_slowrescaling_splines.cpp new file mode 100644 index 000000000..e38ae9bae --- /dev/null +++ b/resources/examples/example1/generate_charm_slowrescaling_splines.cpp @@ -0,0 +1,92 @@ +// Reference generator for the QuarkDISFromSpline slow-rescaling charm-CC splines. +// +// QuarkDISFromSpline samples charm-DIS final states from FITS splines of the +// slow-rescaling differential cross section d2sigma/dxi dy and the total +// sigma(E). The production analysis supplies its own (Dutta-Kim CT14) splines; +// this standalone tool produces physically-validated splines from any installed +// LHAPDF set so the slow-rescaling sampler and normalization can be exercised +// locally (see tests/python/test_quarkdis_slow_rescaling.py). +// +// Physics (leading-order slow rescaling, lab frame, stationary nucleon), exactly +// matching QuarkDISFromSpline's kinematics (slowRescalingQ2 / kinematicallyAllowed): +// xi = struck-quark momentum fraction (the PDF argument) +// Q^2 = 2 M E y xi - m_c^2, W^2 = M^2 + 2 M E y (1-xi) + m_c^2 +// d2sigma/dxi dy = (G_F^2 M E / pi) (M_W^2/(Q^2+M_W^2))^2 +// [ |V_cd|^2 xd(xi,Q^2) + |V_cs|^2 xs(xi,Q^2) +// + (|V_cd|^2 xdbar + |V_cs|^2 xsbar)(1-y)^2 ] +// PDFs are evaluated at xi (NOT at the measured Bjorken x). The integrated charm +// fraction reproduces ~4% at 100 GeV rising to ~6% at 10 TeV (literature band, +// cross-checks the PythiaDISCrossSection charm fraction ~6.5%). +// +// Build/run: +// g++ -std=c++17 $(lhapdf-config --cflags --libs) \ +// generate_charm_slowrescaling_splines.cpp -o gen_sr +// LHAPDF_DATA_PATH= ./gen_sr # writes xidiff.txt + xisigma.txt +// python fit_charm_slowrescaling_splines.py # -> the two FITS splines +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + std::string pdfname = (argc > 1) ? argv[1] : "CT18NLO"; + LHAPDF::PDF* pdf = LHAPDF::mkPDF(pdfname, 0); + + const double GF = 1.1663787e-5, M = 0.9314943, MW = 80.379; + const double mc = 1.280, MD0 = 1.86484, mmu = 0.105658; + const double Vcd2 = 0.221 * 0.221, Vcs2 = 0.975 * 0.975; + const double GEV2_TO_CM2 = 0.389379e-27; // (hbar c)^2 + + auto dsig = [&](double E, double xi, double y) -> double { + double Q2 = 2 * M * E * y * xi - mc * mc; if (Q2 <= 1.0) return 0.0; + double W2 = M * M + 2 * M * E * y * (1 - xi) + mc * mc; + if (W2 <= (M + MD0) * (M + MD0)) return 0.0; + if (y >= 1 - mmu / E) return 0.0; + double P1 = E, pqx = (mmu * mmu + 2 * P1 * P1 + Q2 + 2 * E * E * (y - 1)) / (2 * P1); + double mq2 = P1 * P1 + Q2 + E * E * (y * y - 1); + if (mq2 - pqx * pqx < 0) return 0.0; + if (xi <= 0 || xi >= 1) return 0.0; + double xd = pdf->xfxQ2(1, xi, Q2), xs = pdf->xfxQ2(3, xi, Q2); + double xdb = pdf->xfxQ2(-1, xi, Q2), xsb = pdf->xfxQ2(-3, xi, Q2); + double pref = GF * GF * M * E / M_PI * std::pow(MW * MW / (Q2 + MW * MW), 2); + double v = pref * ((Vcd2 * xd + Vcs2 * xs) + (Vcd2 * xdb + Vcs2 * xsb) * (1 - y) * (1 - y)); + return v > 0 ? v * GEV2_TO_CM2 : 0.0; // cm^2 + }; + + int nE = 20, nxi = 26, ny = 26; + std::vector Ev(nE), lxi(nxi), ly(ny); + for (int i = 0; i < nE; i++) Ev[i] = std::pow(10, 1.0 + 5.0 * i / (nE - 1)); // 10 GeV - 1 PeV + for (int i = 0; i < nxi; i++) lxi[i] = -4.0 + (std::log10(0.95) + 4.0) * i / (nxi - 1); + for (int i = 0; i < ny; i++) ly[i] = -3.0 + (std::log10(0.95) + 3.0) * i / (ny - 1); + + FILE* fd = std::fopen("xidiff.txt", "w"); + std::fprintf(fd, "%d %d %d\n", nE, nxi, ny); + for (double v : Ev) std::fprintf(fd, "%.10e\n", v); + for (double v : lxi) std::fprintf(fd, "%.10e\n", v); + for (double v : ly) std::fprintf(fd, "%.10e\n", v); + for (int i = 0; i < nE; i++) + for (int j = 0; j < nxi; j++) + for (int k = 0; k < ny; k++) { + double v = dsig(Ev[i], std::pow(10, lxi[j]), std::pow(10, ly[k])); + if (v > 0) std::fprintf(fd, "%.8e 1\n", std::log10(v)); + else std::fprintf(fd, "-99 0\n"); + } + std::fclose(fd); + + FILE* fs = std::fopen("xisigma.txt", "w"); + for (double E : Ev) { + int Nx = 400, Ny = 400; double lo = -4, hi = std::log10(0.95), tot = 0; + double ymax = 1 - mmu / E - 1e-3, dy = (ymax - 1e-3) / Ny; + for (int a = 0; a < Nx; a++) { + double l0 = lo + (hi - lo) * a / Nx, l1 = lo + (hi - lo) * (a + 1) / Nx; + double xi = std::pow(10, 0.5 * (l0 + l1)), dxi = std::pow(10, l1) - std::pow(10, l0), col = 0; + for (int b = 0; b < Ny; b++) { double y = 1e-3 + (b + 0.5) * dy; col += dsig(E, xi, y) * dy; } + tot += col * dxi; + } + std::fprintf(fs, "%.10e %.10e\n", E, tot); + } + std::fclose(fs); + std::printf("wrote xidiff.txt (%dx%dx%d) + xisigma.txt with PDF %s\n", nE, nxi, ny, pdfname.c_str()); + return 0; +} diff --git a/resources/examples/example1/reproduce_dimuon_kinematics.py b/resources/examples/example1/reproduce_dimuon_kinematics.py new file mode 100644 index 000000000..cab6cf96e --- /dev/null +++ b/resources/examples/example1/reproduce_dimuon_kinematics.py @@ -0,0 +1,134 @@ +"""Reproduce the charm dimuon kinematics from the IceCube tau/charm update slides +with the full SIREN+Pythia chain (PythiaDISCrossSection -> CharmMesonDecay). + +For E_nu = 100 GeV nu_mu charged-current charm DIS: PythiaDISCrossSection samples +the primary muon + D meson, then the D decays semileptonically to a muon +(CharmMesonDecay, muon channel). This is the 'pythiaSIREN' curve in the +Pythia-vs-pythiaSIREN comparison (Diffuse WG). Saves the five slide observables +to an .npz for plotting against the slide histograms: + + th_mumu : opening angle (primary mu, decay mu) [deg] + zmu : semileptonic D decay energy fraction E_mu/E_D + y : Bjorken-y = 1 - E_mu,prim / E_nu + th_Dmu : opening angle (D meson, primary mu) [deg] + Q2 : Q^2 = 2 E_nu E_mu,prim (1 - cos th_nu,mu) [GeV^2] + +Requires a Pythia8 build + LHAPDF + a total charm spline (SIREN_PYTHIA_WIDE_SIGMA; +see generate_charm_pythia_splines.py and README_charm.md). The means reproduce +the slide panels (theta_mumu ~9 deg core, ~0.25, ~0.56, theta_Dmu ~7 deg, + ~15 GeV^2). +""" +import os +import math +import sys + +import numpy as np + +import siren +import siren.interactions +import siren.dataclasses +import siren.utilities + +PT = siren.dataclasses.Particle.ParticleType +M_N = 0.9314943 +E_NU = 100.0 + + +def _vmag(p): + return math.sqrt(p[1] ** 2 + p[2] ** 2 + p[3] ** 2) + + +def _angle_deg(a, b): + na, nb = _vmag(a), _vmag(b) + if na == 0 or nb == 0: + return float("nan") + c = (a[1] * b[1] + a[2] * b[2] + a[3] * b[3]) / (na * nb) + return math.degrees(math.acos(max(-1.0, min(1.0, c)))) + + +def main(): + out = sys.argv[1] if len(sys.argv) > 1 else "siren_dimuon.npz" + N = int(sys.argv[2]) if len(sys.argv) > 2 else 2500 + sigma_spline = os.environ["SIREN_PYTHIA_WIDE_SIGMA"] + pdf = os.environ.get("SIREN_PYTHIA_PDF", "LHAPDF6:CT18NLO") + pythia_data = os.environ.get("PYTHIA8DATA", "") + + rng = siren.utilities.SIREN_random(20260506) + xs = siren.interactions.PythiaDISCrossSection( + "", sigma_spline, 1, M_N, 1.0, [PT.NuMu], [PT.PPlus], pythia_data, pdf, "cm") + sigs = list(xs.GetPossibleSignaturesFromParents(PT.NuMu, PT.PPlus)) + + th_mumu, zmu, yv, th_Dmu, Q2v = [], [], [], [], [] + decays = {} + n_done = attempts = 0 + while n_done < N and attempts < N * 30: + attempts += 1 + ir = siren.dataclasses.InteractionRecord() + ir.signature = sigs[n_done % len(sigs)] + ir.primary_momentum = [E_NU, 0.0, 0.0, E_NU] + ir.primary_mass = 0.0 + ir.target_mass = M_N + cdr = siren.dataclasses.CrossSectionDistributionRecord(ir) + try: + xs.SampleFinalState(cdr, rng) + except RuntimeError: + continue + ir_out = siren.dataclasses.InteractionRecord() + ir_out.signature = ir.signature + ir_out.primary_momentum = [E_NU, 0.0, 0.0, E_NU] + ir_out.primary_mass = 0.0 + cdr.finalize(ir_out) + secs = list(ir_out.secondary_momenta) + smass = list(ir_out.secondary_masses) + p_lep, p_D = secs[0], secs[2] # primary muon, D meson + D_type = ir_out.signature.secondary_types[2] + E_lep, E_D = p_lep[0], p_D[0] + if E_D <= 0: + continue + + if D_type not in decays: + try: + decays[D_type] = siren.interactions.CharmMesonDecay(primary_type=D_type) + except Exception: + decays[D_type] = None + dec = decays[D_type] + if dec is None: + continue + mu_sig = None + for ds in dec.GetPossibleSignaturesFromParent(D_type): + st = list(ds.secondary_types) + if len(st) == 3 and st[1] in (PT.MuPlus, PT.MuMinus): + mu_sig = ds + break + if mu_sig is None: + continue + drec = siren.dataclasses.InteractionRecord() + drec.signature = mu_sig + drec.primary_momentum = [p_D[0], p_D[1], p_D[2], p_D[3]] + drec.primary_mass = smass[2] + dcdr = siren.dataclasses.CrossSectionDistributionRecord(drec) + try: + dec.SampleFinalState(dcdr, rng) + except RuntimeError: + continue + dout = siren.dataclasses.InteractionRecord() + dout.signature = mu_sig + dout.primary_momentum = [p_D[0], p_D[1], p_D[2], p_D[3]] + dout.primary_mass = smass[2] + dcdr.finalize(dout) + p_mu_dec = list(dout.secondary_momenta)[1] # decay muon + + th_mumu.append(_angle_deg(p_lep, p_mu_dec)) + zmu.append(p_mu_dec[0] / E_D) + yv.append(1.0 - E_lep / E_NU) + th_Dmu.append(_angle_deg(p_D, p_lep)) + Q2v.append(2.0 * E_NU * E_lep * (1.0 - p_lep[3] / _vmag(p_lep))) + n_done += 1 + + np.savez(out, th_mumu=np.array(th_mumu), zmu=np.array(zmu), y=np.array(yv), + th_Dmu=np.array(th_Dmu), Q2=np.array(Q2v)) + print(f"wrote {out} with {n_done} events") + + +if __name__ == "__main__": + main() diff --git a/tests/python/test_controller.py b/tests/python/test_controller.py index 0c37e5b4f..91928a56d 100644 --- a/tests/python/test_controller.py +++ b/tests/python/test_controller.py @@ -482,3 +482,92 @@ def test_multiple_events_give_different_weights(self, full_setup): weighter, events = full_setup weights = [weighter.EventWeight(e) for e in events] assert len(set(weights)) > 1 or len(events) == 1 + + +# --------------------------------------------------------------------------- +# Serialization: injector/weighter save + load round-trips +# --------------------------------------------------------------------------- + +class TestSerialization: + """Guard the injector/weighter serialization the SaveEvents path and the + Weighter wrapper expose. The controller inject->save path was previously + untested, which let a wrapper/pybind API mismatch (.save vs SaveInjector/ + SaveWeighter, and a removed Add*Distribution API) go unnoticed.""" + + def _ccm_pieces(self): + NuMu = dc.Particle.ParticleType.NuMu + dm = detector.DetectorModel() + det_dir = _util.get_detector_model_path("CCM") + dm.LoadMaterialModel(os.path.join(det_dir, "materials.dat")) + dm.LoadDetectorModel(os.path.join(det_dir, "densities.dat")) + int_col = interactions.InteractionCollection(NuMu, [interactions.DummyCrossSection()]) + p_inj = injection.PrimaryInjectionProcess() + p_inj.primary_type = NuMu + p_inj.interactions = int_col + p_inj.distributions = [ + distributions.PrimaryMass(0), distributions.Monoenergetic(1.0), + distributions.IsotropicDirection(), + distributions.PointSourcePositionDistribution(smath.Vector3D(0, 0, 0), 25.0)] + p_phys = injection.PhysicalProcess() + p_phys.primary_type = NuMu + p_phys.interactions = int_col + p_phys.distributions = [distributions.PrimaryMass(0), distributions.IsotropicDirection()] + return NuMu, dm, p_inj, p_phys + + def test_controller_save_events_writes_loadable_injector_and_weighter(self, tmp_path): + """SIREN_Controller inject -> SaveEvents writes .siren_injector / + .siren_weighter via the pybind SaveInjector/SaveWeighter, and the saved + weighter reloads through the (injectors, filename) constructor.""" + from siren.SIREN_Controller import SIREN_Controller + NuMu = dc.Particle.ParticleType.NuMu + try: + c = SIREN_Controller(3, experiment="CCM") + except (AttributeError, TypeError, OSError) as e: + pytest.skip(f"Cannot create CCM controller: {e}") + c.SetInteractions(interactions.InteractionCollection(NuMu, [interactions.DummyCrossSection()])) + inj_d = {"mass": distributions.PrimaryMass(0), "energy": distributions.Monoenergetic(1.0), + "direction": distributions.IsotropicDirection(), + "position": distributions.PointSourcePositionDistribution(smath.Vector3D(0, 0, 0), 25.0)} + phys_d = {"mass": distributions.PrimaryMass(0), "direction": distributions.IsotropicDirection()} + c.SetProcesses(NuMu, inj_d, phys_d) + c.Initialize() + c.GenerateEvents(verbose=False) + base = str(tmp_path / "ev") + c.SaveEvents(base, hdf5=False, parquet=False, verbose=False) + assert os.path.exists(base + ".siren_injector") + assert os.path.exists(base + ".siren_weighter") + w2 = injection._Weighter(c.injectors, base) # (injectors, filename) load ctor + assert np.isfinite(w2.EventWeight(c.events[0])) + + def test_weighter_wrapper_load_needs_only_injectors(self, tmp_path): + """Weighter.load() restores via the (injectors, filename) ctor and must NOT + require the detector / interactions / distributions to be configured.""" + NuMu, dm, p_inj, p_phys = self._ccm_pieces() + inj = injection._Injector(10, dm, p_inj, utilities.SIREN_random(7)) + event = inj.GenerateEvent() + + configured = injection.Weighter( + injectors=[inj], detector_model=dm, primary_type=NuMu, + primary_interactions=[interactions.DummyCrossSection()], + primary_physical_distributions=[distributions.PrimaryMass(0), + distributions.IsotropicDirection()]) + base = str(tmp_path / "w") + configured.save(base) + ref = configured(event) + + # a fresh wrapper that ONLY knows the injectors -- no detector/process config + fresh = injection.Weighter(injectors=[inj]) + fresh.load(base) + assert np.isclose(fresh(event), ref) + + def test_injector_save_reload_roundtrip(self, tmp_path): + """Injector SaveInjector writes the literal path; reload via the filename ctor.""" + NuMu, dm, p_inj, p_phys = self._ccm_pieces() + inj = injection._Injector(10, dm, p_inj, utilities.SIREN_random(11)) + path = str(tmp_path / "inj.siren_injector") + inj.SaveInjector(path) + assert os.path.getsize(path) > 0 + inj2 = injection._Injector(10, path, utilities.SIREN_random(11)) # filename ctor + ev = inj2.GenerateEvent() + w = injection._Weighter([inj2], dm, p_phys) + assert np.isfinite(w.EventWeight(ev)) diff --git a/tests/python/test_pythia_charm_validation.py b/tests/python/test_pythia_charm_validation.py new file mode 100644 index 000000000..aa8b8105d --- /dev/null +++ b/tests/python/test_pythia_charm_validation.py @@ -0,0 +1,338 @@ +"""Physics validation for the PythiaDISCrossSection charm-DIS generator. + +Guards the absolute charm-production rate, that SampleFinalState reproduces bare +Pythia's DIS kinematics (Bjorken x/y, Q^2), and that an optional differential +spline covers the realized sampling support (no silent-zero weight bias) across +the TeV-PeV band. Needs Pythia8/LHAPDF at runtime plus charm splines; gated behind +env vars, skips cleanly when unset: + + SIREN_PYTHIA_WIDE_SIGMA -> total sigma(E) FITS spline, 100 GeV - 1 PeV + SIREN_PYTHIA_WIDE_DSDXDY -> differential d2sigma/dx dy FITS spline (optional) + LHAPDF_DATA_PATH -> LHAPDF data dir containing the PDF set below + +SampleFinalState re-inits Pythia per event (~1 s/event) so SIREN statistics are +modest; the bare-Pythia reference (GeneratePythiaCharmSamples, ~ms/event) is high-stat. +""" +import math +import os + +import pytest + +siren = pytest.importorskip("siren") + +PDF_SET = os.environ.get("SIREN_PYTHIA_PDF", "LHAPDF6:CT18NLO") +PYTHIA_DATA = os.environ.get("PYTHIA8DATA", "") +M_N = 0.9314943 # isoscalar nucleon mass (GeV) +M_MU = 0.105658 + +_SIGMA = os.environ.get("SIREN_PYTHIA_WIDE_SIGMA") +_DSDXDY = os.environ.get("SIREN_PYTHIA_WIDE_DSDXDY") +_LHAPDF = os.environ.get("LHAPDF_DATA_PATH") + +_have_total = bool(_SIGMA and os.path.exists(_SIGMA) and _LHAPDF) +_have_diff = bool(_DSDXDY and os.path.exists(_DSDXDY)) + +_pythia_reason = ( + "set SIREN_PYTHIA_WIDE_SIGMA (wide total spline) and LHAPDF_DATA_PATH, and " + "build with SIREN_WITH_PYTHIA8=ON, to run the charm validation tests" +) + +# PythiaDISCrossSection is only registered when SIREN is built with Pythia8. +_has_pythia_class = hasattr(getattr(siren, "interactions", object()), "PythiaDISCrossSection") + +pytestmark = pytest.mark.skipif( + not (_have_total and _has_pythia_class), reason=_pythia_reason +) + + +def _make_xs(with_differential): + import siren.interactions + import siren.dataclasses + PT = siren.dataclasses.Particle.ParticleType + diff = _DSDXDY if (with_differential and _have_diff) else "" + return siren.interactions.PythiaDISCrossSection( + diff, _SIGMA, 1, M_N, 1.0, [PT.NuMu], [PT.PPlus], + PYTHIA_DATA, PDF_SET, "cm") + + +def _sample_siren(xs, E, n): + """SampleFinalState n charm events at neutrino energy E (GeV). + + Returns a list of dicts with the stored Bjorken (x, y), the finalized D and + primary-lepton 4-momenta, and the D/lepton energy fractions. + """ + import siren.dataclasses + import siren.utilities + PT = siren.dataclasses.Particle.ParticleType + rng = siren.utilities.SIREN_random(20260506) + sigs = list(xs.GetPossibleSignaturesFromParents(PT.NuMu, PT.PPlus)) + out = [] + attempts = 0 + while len(out) < n and attempts < n * 20: + attempts += 1 + sig = sigs[len(out) % len(sigs)] + ir = siren.dataclasses.InteractionRecord() + ir.signature = sig + ir.primary_momentum = [E, 0.0, 0.0, E] + ir.primary_mass = 0.0 + ir.target_mass = M_N + cdr = siren.dataclasses.CrossSectionDistributionRecord(ir) + try: + xs.SampleFinalState(cdr, rng) + except RuntimeError: + continue + params = dict(cdr.interaction_parameters) + ir_out = siren.dataclasses.InteractionRecord() + ir_out.signature = ir.signature + ir_out.primary_momentum = [E, 0.0, 0.0, E] + ir_out.primary_mass = 0.0 + cdr.finalize(ir_out) + secs = list(ir_out.secondary_momenta) + # secondary order: [charged lepton, Hadrons, D meson] + p_lep, p_D = secs[0], secs[2] + out.append(dict(x=params["bjorken_x"], y=params["bjorken_y"], + E=E, p_lep=p_lep, p_D=p_D, + zD=p_D[0] / E, zlep=p_lep[0] / E, + ir_out=ir_out)) + return out + + +def _bare_pythia_xy(E, n_events): + """Bare-Pythia muon-reconstructed (x, y) for an ice p/n mix (10:8).""" + import siren.interactions + PT = siren.interactions.PythiaDISCrossSection + xs_all, ys_all = [], [] + for target_pdg, w in ((2212, 10), (2112, 8)): + _, _, x, y = PT.GeneratePythiaCharmSamples( + 1, 14, target_pdg, M_N, PDF_SET, PYTHIA_DATA, 1.0, + [float(E)], int(n_events * w / 18)) + xs_all += list(x) + ys_all += list(y) + return xs_all, ys_all + + +def _mean(v): + return sum(v) / len(v) + + +# Test 1: absolute charm-production rate / charm fraction (normalization). +def test_charm_total_cross_section_normalization(): + """The inclusive charm-DIS sigma must have the right absolute magnitude. + + Pins (a) charm fraction sigma_charm/sigma_CC at 100 GeV vs the textbook nu-N CC + cross section, and (b) sigma_charm/E to the right order of magnitude across + 100 GeV - 1 PeV with monotonic growth. + """ + import siren.dataclasses + PT = siren.dataclasses.Particle.ParticleType + xs = _make_xs(with_differential=False) + + # (a) charm fraction at 100 GeV (CC sigma is textbook-linear there). + sigma_cc_ref_100 = 0.677e-38 * 100.0 # cm^2, sigma_CC/E ~ 0.677e-38 cm^2/GeV + sigma_charm_100 = xs.TotalCrossSection(PT.NuMu, 100.0) + frac = sigma_charm_100 / sigma_cc_ref_100 + assert 0.03 < frac < 0.12, ( + f"charm fraction at 100 GeV = {frac:.3f} outside the literature band " + f"[0.03, 0.12] (sigma_charm={sigma_charm_100:.3e} cm^2)") + + # (b) order-of-magnitude of sigma_charm/E + monotonic growth across the band. + energies = [1.0e2, 1.0e3, 1.0e4, 1.0e5, 1.0e6] + sig = [xs.TotalCrossSection(PT.NuMu, E) for E in energies] + for E, s in zip(energies, sig): + assert s > 0.0 + soe = s / E + assert 5e-41 < soe < 5e-39, ( + f"sigma_charm/E = {soe:.3e} cm^2/GeV at E={E:.0e} GeV is out of the " + "expected charm-DIS magnitude band") + for a, b in zip(sig, sig[1:]): + assert b > a, "charm cross section must increase with energy" + + +# Test 2: SampleFinalState reproduces bare-Pythia DIS kinematics. +@pytest.mark.skipif(not PYTHIA_DATA, reason="set PYTHIA8DATA to run the Pythia-sampling tests") +def test_sampling_matches_bare_pythia_at_100gev(): + """SIREN SampleFinalState must reproduce bare Pythia's Bjorken x/y at 100 GeV. + + SampleFinalState extracts/rotates the Pythia final state and reconstructs + (x, y); GeneratePythiaCharmSamples is the same Pythia config inline. A + frame/extraction bug would show here as a distribution mismatch. + """ + E = 100.0 + xs = _make_xs(with_differential=False) + siren_ev = _sample_siren(xs, E, n=80) + assert len(siren_ev) >= 40, f"only {len(siren_ev)} SIREN events sampled" + x_si = [e["x"] for e in siren_ev] + y_si = [e["y"] for e in siren_ev] + + x_py, y_py = _bare_pythia_xy(E, n_events=4000) + assert len(x_py) > 1000 + + # Compare means; tolerance is set by the modest SIREN sample size. + def _stderr(v): + m = _mean(v) + var = sum((vi - m) ** 2 for vi in v) / max(1, len(v) - 1) + return math.sqrt(var / len(v)) + + for name, vi_si, vi_py in (("y", y_si, y_py), ("x", x_si, x_py)): + m_si, m_py = _mean(vi_si), _mean(vi_py) + tol = 4.0 * (_stderr(vi_si) + _stderr(vi_py)) + 0.05 * m_py + assert abs(m_si - m_py) < tol, ( + f"mean Bjorken-{name}: SIREN={m_si:.4f} vs bare-Pythia={m_py:.4f} " + f"(tol {tol:.4f}) -- SampleFinalState does not reproduce Pythia") + + # Physical kinematics. + assert all(0.0 < e["x"] < 1.0 for e in siren_ev) + assert all(0.0 < e["y"] < 1.0 for e in siren_ev) + + +# Test 3: D-meson energy fraction + production collimation (morphology). +@pytest.mark.skipif(not PYTHIA_DATA, reason="set PYTHIA8DATA to run the Pythia-sampling tests") +def test_d_meson_energy_fraction_and_collimation(): + """The D meson carries a sizeable, physical energy fraction and is nearly + collinear with the primary lepton at high energy -- the morphology setting the + two-cascade energy split and separation direction. + """ + E = 1.0e4 # 10 TeV + xs = _make_xs(with_differential=False) + ev = _sample_siren(xs, E, n=60) + assert len(ev) >= 30 + zD = [e["zD"] for e in ev] + # Every D carries a physical (0, 1) energy fraction, mean in a sane range. + assert all(0.0 < z < 1.0 for z in zD) + assert 0.05 < _mean(zD) < 0.95 + # D/lepton opening angle is small at 10 TeV (collimated). + def _angle(a, b): + import math + na = math.sqrt(sum(a[i] ** 2 for i in (1, 2, 3))) + nb = math.sqrt(sum(b[i] ** 2 for i in (1, 2, 3))) + dot = sum(a[i] * b[i] for i in (1, 2, 3)) + c = max(-1.0, min(1.0, dot / (na * nb))) + return math.degrees(math.acos(c)) + angles = [_angle(e["p_D"], e["p_lep"]) for e in ev] + # Modest median (DIS at 10 TeV is forward). + angles.sort() + median = angles[len(angles) // 2] + assert median < 30.0, f"median D/lepton opening angle {median:.1f} deg too large" + + +# Test 4: optional differential spline covers the sampled support (no silent 0). +@pytest.mark.skipif(not (_have_diff and PYTHIA_DATA), + reason="set SIREN_PYTHIA_WIDE_DSDXDY + PYTHIA8DATA to run the coverage test") +def test_differential_spline_covers_sampling_support(): + """With a differential spline, DifferentialCrossSection must be finite-positive + on essentially every sampled event, else those events get a silently-zero + density and a biased weight. Guards spline (E, x, y) support vs realized support. + """ + xs = _make_xs(with_differential=True) + for E in (1.0e3, 1.0e4): # 1 TeV, 10 TeV (within the wide spline x-range) + ev = _sample_siren(xs, E, n=60) + assert len(ev) >= 30, f"too few events at E={E:.0e}" + in_range = 0 + out_of_range = 0 + silent_zero = 0 + for e in ev: + try: + v = xs.DifferentialCrossSection(e["ir_out"]) + except RuntimeError: + out_of_range += 1 # out-of-support correctly RAISES + continue + if math.isfinite(v) and v > 0.0: + in_range += 1 + else: + silent_zero += 1 + # Contract: out-of-range raises; nothing returns a silent zero. + assert silent_zero == 0, ( + f"{silent_zero} sampled events at E={E:.0e} GeV returned a silent-zero " + "differential density instead of raising") + frac = in_range / len(ev) + assert frac > 0.95, ( + f"only {frac:.3f} of sampled events at E={E:.0e} GeV fell inside the " + "differential spline (E, x, y) support; widen the spline's logx/logy range.") + + +# Test 5: end-to-end inject -> weight through the real Injector / Weighter. +@pytest.mark.skipif(not PYTHIA_DATA, reason="set PYTHIA8DATA to run the Pythia-sampling tests") +def test_end_to_end_rate_closure(): + """Quantitative inject->weight rate closure for charm DIS through the real + _Injector / _Weighter on CCM. + + With injection==physical the shared distributions, cross-section probability, + and PointSource position propagator all cancel, leaving EventWeight_i = + InteractionProbability_i / N. Therefore: + - per event, EventWeight == GetInteractionProbabilities/N to machine + precision; they are independent code paths, so agreement IS the + unbiasedness proof -- the PR#74 SampleFinalState/FinalStateProbability/ + TotalCrossSection closure-break class would break it, and + - sum(EventWeight) == mean(P_int), bounded by a thin-target sigma*n_Ar*L_eff. + """ + import numpy as np + from siren import (dataclasses as dc, injection, interactions, distributions, + detector, math as smath, utilities, _util) + PT = dc.Particle.ParticleType + NuMu = PT.NuMu + + det_dir = _util.get_detector_model_path("CCM") + dm = detector.DetectorModel() + dm.LoadMaterialModel(os.path.join(det_dir, "materials.dat")) + dm.LoadDetectorModel(os.path.join(det_dir, "densities.dat")) + + xs = interactions.PythiaDISCrossSection( + "", _SIGMA, 1, M_N, 1.0, [NuMu], [PT.Ar40Nucleus], PYTHIA_DATA, PDF_SET, "cm") + int_col = interactions.InteractionCollection(NuMu, [xs]) + + pinj = injection.PrimaryInjectionProcess() + pinj.primary_type = NuMu + pinj.interactions = int_col + pinj.distributions = [ + distributions.PrimaryMass(0), + distributions.Monoenergetic(1000.0), # 1 TeV nu_mu + distributions.IsotropicDirection(), + distributions.PointSourcePositionDistribution(smath.Vector3D(0, 0, 0), 5.0), + ] + pphys = injection.PhysicalProcess() + pphys.primary_type = NuMu + pphys.interactions = int_col + pphys.distributions = [distributions.PrimaryMass(0), distributions.IsotropicDirection()] + + rand = utilities.SIREN_random(7) + E0 = 1000.0 # 1 TeV monoenergetic + pinj.distributions = [ + distributions.PrimaryMass(0), + distributions.Monoenergetic(E0), + distributions.IsotropicDirection(), + distributions.PointSourcePositionDistribution(smath.Vector3D(0, 0, 0), 5.0), + ] + N = 10 + inj = injection._Injector(N, dm, pinj, rand) + weighter = injection._Weighter([inj], dm, pphys) + + weights, int_probs = [], [] + for _ in range(N): + ev = inj.GenerateEvent() + w = weighter.EventWeight(ev) + ip = weighter.GetInteractionProbabilities(ev, 0)[0] + assert np.isfinite(w) and w > 0.0, f"non-finite/non-positive event weight {w}" + weights.append(w) + int_probs.append(ip) + + # (1) Per-event unbiasedness: EventWeight == single-event interaction probability / N. + for w, ip in zip(weights, int_probs): + assert abs(w - ip / N) <= 1e-9 * (ip / N), f"EventWeight {w} != P_int/N {ip / N}" + + # (2) Sum of weights is the unbiased physical-rate estimator == mean(P_int). + sum_w = sum(weights) + mean_ip = sum(int_probs) / N + assert abs(sum_w - mean_ip) <= 1e-9 * mean_ip + + # (3) finite, positive, genuinely varying. + assert len(set(weights)) > 1, "all event weights identical -- sampling did not vary" + + # (4) Analytic thin-target anchor: sum_w ~ sigma_charm * n_Ar * L_eff, with the + # effective LAr path L_eff between ~0 and the 5 m injection cap (isotropic rays + # exit the volume). sigma is the per-nucleon charm spline value at E0. + sigma_cm2 = xs.TotalCrossSection(NuMu, E0) + n_Ar = 1.396 * 6.022e23 / 40.0 # CCM liquid-argon number density [cm^-3] + L_max_cm = 500.0 + upper = sigma_cm2 * n_Ar * L_max_cm + assert 0.0 < sum_w < upper, f"sum_w {sum_w:.3e} not in (0, sigma*n*L_max={upper:.3e})" + assert sum_w > 0.02 * upper, f"sum_w {sum_w:.3e} implausibly small vs thin-target {upper:.3e}" diff --git a/tests/python/test_quarkdis_slow_rescaling.py b/tests/python/test_quarkdis_slow_rescaling.py new file mode 100644 index 000000000..ff4c9f84f --- /dev/null +++ b/tests/python/test_quarkdis_slow_rescaling.py @@ -0,0 +1,354 @@ +"""Slow-rescaling (xi, y) sampling tests for QuarkDISFromSpline. + +The charm FITS splines are LHAPDF-derived and not committed, so the whole module +is gated behind SIREN_CHARM_SPLINE_DIR (a directory containing +dsdxidy_nu-N-cc-charm-CT14nlo_central.fits and sigma_nu-N-cc-charm-CT14nlo_central.fits); +it skips cleanly when unset or the files are absent. SIREN_CHARM_NEVENTS (default +2000) sizes the differential-positivity test. +""" +import math +import os + +import pytest + +siren = pytest.importorskip("siren") + +# Constants (mirror C++ siren::utilities::Constants values exactly). +M_C = 1.27 # Constants::charmMass +M_D0 = 1.86484 # Constants::D0Mass +M_N = (0.938272 + 0.939565) / 2 # isoscalar nucleon mass +M_MU = 0.105658 # muon mass +Q2MIN = 1.0 # GeV^2 +E_NU = 100.0 # neutrino energy in GeV + +N_BOUNDS = 100 # cheap kinematic-bounds test +N_DIFF = int(os.environ.get("SIREN_CHARM_NEVENTS", "2000")) # differential test + +# Per-event resample budget on recoverable InjectionFailure (RuntimeError in +# Python); a direct SampleFinalState caller must retry as the injector does. +MAX_RETRIES = 100 + +# Spline-file gating: resolve spline paths from SIREN_CHARM_SPLINE_DIR. +_SPLINE_DIR = os.environ.get("SIREN_CHARM_SPLINE_DIR") +_DIFF_FILE = ( + os.path.join(_SPLINE_DIR, "dsdxidy_nu-N-cc-charm-CT14nlo_central.fits") + if _SPLINE_DIR + else None +) +_TOTAL_FILE = ( + os.path.join(_SPLINE_DIR, "sigma_nu-N-cc-charm-CT14nlo_central.fits") + if _SPLINE_DIR + else None +) +_have_splines = bool( + _SPLINE_DIR + and _DIFF_FILE + and _TOTAL_FILE + and os.path.exists(_DIFF_FILE) + and os.path.exists(_TOTAL_FILE) +) + +pytestmark = pytest.mark.skipif( + not _have_splines, + reason=( + "set SIREN_CHARM_SPLINE_DIR to a directory containing " + "dsdxidy_nu-N-cc-charm-*.fits and sigma_nu-N-cc-charm-*.fits " + "to run the charm slow-rescaling tests" + ), +) + + +# Expected kinematic bounds (mirror C++ SampleFinalState sampling bounds). +Y_MAX = 1.0 - M_MU / E_NU +W2_THR = (M_N + M_D0) ** 2 +Y_MIN = (W2_THR - M_N ** 2 + Q2MIN) / (2.0 * M_N * E_NU) +XI_MIN = (M_C ** 2 + Q2MIN) / (2.0 * M_N * E_NU * Y_MAX) + + +@pytest.fixture(scope="module") +def charm_xs(): + """Build the QuarkDISFromSpline cross section once for the module.""" + import siren.interactions + import siren.dataclasses + + PT = siren.dataclasses.Particle.ParticleType + xs = siren.interactions.QuarkDISFromSpline( + _DIFF_FILE, + _TOTAL_FILE, + int(1), # interaction_type: CC + M_N, # isoscalar target mass + int(1), # minimum Q2 (GeV^2) + [PT.NuMu], # primary types + [PT.O16Nucleus], # target types + "m", # units + ) + return xs + + +@pytest.fixture(scope="module") +def signature(charm_xs): + sigs = list(charm_xs.GetPossibleSignatures()) + assert len(sigs) > 0, "QuarkDISFromSpline returned no signatures" + return sigs[0] + + +@pytest.fixture +def rng(): + import siren.utilities + return siren.utilities.SIREN_random(1234) + + +def _make_neutrino_record(sig): + """A fresh input InteractionRecord for a 100 GeV nu_mu along +z.""" + import siren.dataclasses + ir = siren.dataclasses.InteractionRecord() + ir.signature = sig + ir.primary_momentum = [E_NU, 0.0, 0.0, E_NU] # massless nu along +z + ir.primary_mass = 0.0 + ir.target_mass = M_N + return ir + + +def _sample_with_retries(charm_xs, sig, rng): + """Sample one event, retrying on recoverable InjectionFailure (RuntimeError). + + Returns the populated record, or None if the resample budget was exhausted + (a rare, expected NaN-guard rejection, not a test failure). + """ + import siren.dataclasses + ir = _make_neutrino_record(sig) + for retry in range(MAX_RETRIES + 1): + cdr = siren.dataclasses.CrossSectionDistributionRecord(ir) + try: + charm_xs.SampleFinalState(cdr, rng) + return cdr + except RuntimeError: + # Recoverable per-event failure; resample. Config errors fire in fixtures. + if retry < MAX_RETRIES: + continue + return None + return None + + +# Test 1: kinematic bounds on the sampled (xi, y) over 100 events. +def test_quarkdis_kinematic_bounds(charm_xs, signature, rng): + """Every sampled event must satisfy the slow-rescaling kinematic bounds.""" + assert XI_MIN < 1.0, "degenerate bounds: xi_min >= 1" + assert Y_MIN < Y_MAX, "degenerate bounds: y_min >= y_max" + + failures = [] + rejected = 0 + sampled = 0 + + for event_idx in range(N_BOUNDS): + cdr = _sample_with_retries(charm_xs, signature, rng) + if cdr is None: + rejected += 1 + continue + sampled += 1 + + params = dict(cdr.interaction_parameters) + xi = params["bjorken_xi"] + y = params["bjorken_y"] + x = params["bjorken_x"] + + # Mirror C++ slow-rescaling relations. + Q2 = 2.0 * M_N * E_NU * y * xi - M_C ** 2 + W2 = M_N ** 2 + 2.0 * M_N * E_NU * y * (1.0 - xi) + M_C ** 2 + + event_failures = [] + if not (XI_MIN <= xi <= 1.0): + event_failures.append(f"xi={xi:.6g} not in [{XI_MIN:.6g}, 1.0]") + if not (Y_MIN <= y <= Y_MAX): + event_failures.append( + f"y={y:.6g} not in [{Y_MIN:.6g}, {Y_MAX:.6g}]" + ) + if not (x > 0.0): + event_failures.append(f"x={x:.6g} not > 0") + if not (Q2 >= Q2MIN): + event_failures.append(f"Q2={Q2:.6g} < Q2MIN={Q2MIN:.6g}") + if not (W2 > W2_THR): + event_failures.append(f"W2={W2:.6g} not > W2_thr={W2_THR:.6g}") + + if event_failures: + failures.append( + f"event {event_idx}: xi={xi:.6g} y={y:.6g} x={x:.6g} " + f"Q2={Q2:.6g} W2={W2:.6g} | " + "; ".join(event_failures) + ) + + # Most slots must sample; a few NaN-guard rejections are tolerated. + assert sampled > 0, "no events were sampled at all" + assert rejected <= N_BOUNDS // 10, ( + f"{rejected}/{N_BOUNDS} events were unrecoverable " + f"(expected at most {N_BOUNDS // 10})" + ) + assert not failures, ( + f"{len(failures)} of {sampled} sampled events violated kinematic " + "bounds:\n" + "\n".join(failures[:10]) + ) + + +# Test 2: differential cross section is finite-positive on the production path. +def test_quarkdis_differential_positive(charm_xs, signature, rng): + """Re-evaluate the spline on finalized records via the production path. + + Drives the weighting density exactly as the Weighter does: SampleFinalState + -> cdr.finalize(ir_out) -> DifferentialCrossSection on the finalized record + (primary-momentum Q2 branch). Asserts a high finite-positive fraction. + """ + import siren.dataclasses + + positive = [] + nonpositive = 0 + eval_errors = 0 + rejected = 0 + sampled = 0 + + n_secondaries = len(signature.secondary_types) + + for event_idx in range(N_DIFF): + cdr = _sample_with_retries(charm_xs, signature, rng) + if cdr is None: + rejected += 1 + continue + sampled += 1 + + # Production path: materialize the sampled state into an output record. + ir_out = siren.dataclasses.InteractionRecord() + ir_out.signature = signature + ir_out.primary_momentum = [E_NU, 0.0, 0.0, E_NU] + ir_out.primary_mass = 0.0 + cdr.finalize(ir_out) + + assert len(ir_out.secondary_momenta) == n_secondaries, ( + f"event {event_idx}: finalize wrote " + f"{len(ir_out.secondary_momenta)} secondary momenta, " + f"expected {n_secondaries}" + ) + + try: + val = charm_xs.DifferentialCrossSection(ir_out) + except Exception: + eval_errors += 1 + continue + + if math.isfinite(val) and val > 0.0: + positive.append(val) + else: + nonpositive += 1 + + assert sampled > 0, "no events were sampled at all" + assert eval_errors == 0, ( + f"{eval_errors} of {sampled} DifferentialCrossSection evaluations " + "raised on the production (finalized) path" + ) + + positive_fraction = len(positive) / sampled + assert positive_fraction > 0.95, ( + f"only {positive_fraction:.4f} of {sampled} finalized records had a " + f"finite-positive DifferentialCrossSection ({nonpositive} non-positive)" + ) + + mean_log_xs = sum(math.log10(v) for v in positive) / len(positive) + assert math.isfinite(mean_log_xs), ( + f"mean log10 differential cross section is not finite: {mean_log_xs}" + ) + + +# Test 3: Sample == Density closure on the finalized record. +def test_quarkdis_sample_density_closure(charm_xs, signature, rng): + """The two Q2 derivations in DifferentialCrossSection must agree. + + Primary-momentum branch (real secondary momenta) vs stored-(xi, y) fallback + branch (momenta absent) must yield the same value for a self-consistent event; + FinalStateProbability (= dxs/txs, the Weighter's density) must be finite >= 0. + """ + import siren.dataclasses + + checked = 0 + for event_idx in range(N_BOUNDS): + cdr = _sample_with_retries(charm_xs, signature, rng) + if cdr is None: + continue + + params = dict(cdr.interaction_parameters) + + # Finalized record: primary-momentum Q2 branch. + ir_out = siren.dataclasses.InteractionRecord() + ir_out.signature = signature + ir_out.primary_momentum = [E_NU, 0.0, 0.0, E_NU] + ir_out.primary_mass = 0.0 + cdr.finalize(ir_out) + dxs_primary = charm_xs.DifferentialCrossSection(ir_out) + + if not (math.isfinite(dxs_primary) and dxs_primary > 0.0): + # Closure is only meaningful where dxs is positive on both paths. + continue + + # Same record without secondary momenta: stored-(xi, y) fallback branch. + ir_fb = siren.dataclasses.InteractionRecord() + ir_fb.signature = signature + ir_fb.primary_momentum = [E_NU, 0.0, 0.0, E_NU] + ir_fb.primary_mass = 0.0 + ir_fb.target_mass = M_N + ir_fb.secondary_momenta = [[0.0, 0.0, 0.0, 0.0]] * len(signature.secondary_types) + ir_fb.secondary_masses = [0.0] * len(signature.secondary_types) + ir_fb.interaction_parameters = { + "energy": E_NU, + "bjorken_xi": params["bjorken_xi"], + "bjorken_y": params["bjorken_y"], + } + dxs_fallback = charm_xs.DifferentialCrossSection(ir_fb) + + # Same spline at the same (E, xi, y): agree to within Q2-derivation roundoff. + rel = abs(dxs_primary - dxs_fallback) / max(dxs_primary, dxs_fallback) + assert rel < 1e-3, ( + f"event {event_idx}: primary-path dxs={dxs_primary:.6g} disagrees " + f"with fallback dxs={dxs_fallback:.6g} (rel={rel:.3g}); the two Q2 " + "derivations in DifferentialCrossSection are inconsistent" + ) + + # Weighter's physical density. + fsp = charm_xs.FinalStateProbability(ir_out) + assert math.isfinite(fsp), f"event {event_idx}: FinalStateProbability not finite: {fsp}" + assert fsp >= 0.0, f"event {event_idx}: FinalStateProbability negative: {fsp}" + + checked += 1 + + assert checked > 0, "no positive-dxs events were available for closure check" + + +# Test 4: absolute charm-DIS normalization (charm fraction vs literature/Pythia). +def test_quarkdis_charm_fraction_normalization(): + """The inclusive slow-rescaling charm-CC cross section must have the right + absolute magnitude: the charm fraction sigma_charm / sigma_CC must land in the + literature band (~4-7% over 100 GeV - 1 TeV), cross-validating the independent + PythiaDISCrossSection charm fraction (~6.5% at 100 GeV). The spline is read in + cm so TotalCrossSection returns cm^2 (the per-nucleon reference below is cm^2). + """ + import siren.interactions + import siren.dataclasses + + PT = siren.dataclasses.Particle.ParticleType + xs = siren.interactions.QuarkDISFromSpline( + _DIFF_FILE, _TOTAL_FILE, int(1), M_N, int(1), + [PT.NuMu], [PT.O16Nucleus], "cm") + + sigma_cc_per_gev = 0.677e-38 # textbook nu-N CC sigma/E [cm^2/GeV] + prev = None + n_checked = 0 + for E in (100.0, 200.0, 300.0): + try: + s = xs.TotalCrossSection(PT.NuMu, E) + except RuntimeError: + continue # energy outside the provided spline range + assert s > 0.0 + frac = s / (sigma_cc_per_gev * E) + assert 0.02 < frac < 0.10, ( + f"charm fraction {frac:.3f} at {E:.0f} GeV outside the literature band " + "[0.02, 0.10]") + if prev is not None: + assert s > prev, "charm cross section must increase with energy" + prev = s + n_checked += 1 + assert n_checked >= 1, "no in-range energy point was available to normalize" diff --git a/vendor/cereal b/vendor/cereal index 02eace19a..d1fcec807 160000 --- a/vendor/cereal +++ b/vendor/cereal @@ -1 +1 @@ -Subproject commit 02eace19a99ce3cd564ca4e379753d69af08c2c8 +Subproject commit d1fcec807b372f04e4c1041b3058e11c12853e6e diff --git a/vendor/photospline b/vendor/photospline index bb68658a8..53013f709 160000 --- a/vendor/photospline +++ b/vendor/photospline @@ -1 +1 @@ -Subproject commit bb68658a8776b9dabba8c3d3332b4294474d20c3 +Subproject commit 53013f709df16e2eb71a1b557a304718207ed46a