diff --git a/.github/workflows/wheels.yaml b/.github/workflows/wheels.yaml index a611ad68..e0cf12f1 100644 --- a/.github/workflows/wheels.yaml +++ b/.github/workflows/wheels.yaml @@ -2,6 +2,12 @@ name: Wheels on: workflow_dispatch: + inputs: + version: + description: + "Version to stamp on test builds (e.g. 0.1.0rc1); empty uses git" + required: false + default: "" schedule: - cron: "0 4 * * *" # every day at 4am UTC @@ -14,7 +20,11 @@ env: GEANT4_VERSION: 11.2.2 XERCES_VERSION: 3.2.5 CPP_STANDARD: 17 - Geant4_DIR: ${{ github.workspace }}/geant4 + # Qt the viewer is built against. Headers and tools come from the official + # Qt 5.15.2 archive; at runtime the [gui] extra supplies Qt from PyQt5-Qt5. + QT_VERSION: 5.15.2 + PYQT5_QT5_VERSION: 5.15.19 + MACOSX_DEPLOYMENT_TARGET: "11.0" concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -27,6 +37,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v5 @@ -34,7 +46,10 @@ jobs: python-version: 3.12 - name: Build sdist + env: + SETUPTOOLS_SCM_PRETEND_VERSION: ${{ inputs.version }} run: | + [ -n "$SETUPTOOLS_SCM_PRETEND_VERSION" ] || unset SETUPTOOLS_SCM_PRETEND_VERSION python -m pip install build python -m build --sdist . @@ -45,159 +60,178 @@ jobs: path: dist/*.tar.gz wheels: - name: - Build wheel for cp${{ matrix.python }} ${{ matrix.platform_id }} ${{ - matrix.manylinux_image }} + name: Wheels for ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - - os: ubuntu-latest - python: 38 - platform_id: manylinux_x86_64 - manylinux_image: manylinux2014 - - os: ubuntu-latest - python: 39 - platform_id: manylinux_x86_64 - manylinux_image: manylinux2014 - - os: ubuntu-latest - python: 310 - platform_id: manylinux_x86_64 - manylinux_image: manylinux2014 - - os: ubuntu-latest - python: 311 - platform_id: manylinux_x86_64 - manylinux_image: manylinux2014 - - os: ubuntu-latest - python: 312 - platform_id: manylinux_x86_64 - manylinux_image: manylinux2014 - - - os: macos-latest - python: 38 - platform_id: macosx_x86_64 - - os: macos-latest - python: 39 - platform_id: macosx_x86_64 - - os: macos-latest - python: 310 - platform_id: macosx_x86_64 - - os: macos-latest - python: 311 - platform_id: macosx_x86_64 - - os: macos-latest - python: 312 - platform_id: macosx_x86_64 - - - os: macos-latest - python: 38 - platform_id: macosx_arm64 - - os: macos-latest - python: 39 - platform_id: macosx_arm64 - - os: macos-latest - python: 310 - platform_id: macosx_arm64 - - os: macos-latest - python: 311 - platform_id: macosx_arm64 - - os: macos-latest - python: 312 - platform_id: macosx_arm64 - - - os: windows-latest - python: 38 - platform_id: win_amd64 - - os: windows-latest - python: 39 - platform_id: win_amd64 - - os: windows-latest - python: 310 - platform_id: win_amd64 - - os: windows-latest - python: 311 - platform_id: win_amd64 - - os: windows-latest - python: 312 - platform_id: win_amd64 + - name: Linux x86_64 + os: ubuntu-latest + arch: x86_64 + - name: macOS arm64 + os: macos-15 + arch: arm64 + pyqt5_qt5_platform: macosx_11_0_arm64 + - name: macOS x86_64 + os: macos-15-intel + arch: x86_64 + pyqt5_qt5_platform: macosx_10_13_x86_64 + env: + Geant4_DIR: ${{ github.workspace }}/geant4 + QT_PREFIX: ${{ github.workspace }}/qt/5.15.2/clang_64 steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.12 - name: Setup CMake + if: runner.os == 'macOS' uses: lukka/get-cmake@v3.30.3 - - name: Setup Windows environment variables - if: matrix.os == 'windows-latest' - shell: bash - run: | - echo "CMAKE_ARGS=-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded" >> $GITHUB_ENV + - name: Install Rosetta (runs the x86_64 Qt 5.15.2 build tools) + if: runner.os == 'macOS' && matrix.arch == 'arm64' + run: sudo softwareupdate --install-rosetta --agree-to-license || true - - name: Setup MacOS environment variables - if: matrix.os == 'macos-latest' + - name: Cache Qt + if: runner.os == 'macOS' + id: cache-qt + uses: actions/cache/restore@v4 + with: + path: ${{ github.workspace }}/qt + key: + qt-${{ env.QT_VERSION }}-pyqt5-qt5-${{ env.PYQT5_QT5_VERSION }}-${{ + matrix.pyqt5_qt5_platform }} + + - name: Install Qt (official headers and tools, PyQt5-Qt5 libraries) + if: runner.os == 'macOS' && steps.cache-qt.outputs.cache-hit != 'true' run: | - echo "CMAKE_ARGS=-DCMAKE_OSX_ARCHITECTURES=${{ matrix.platform_id == 'macosx_x86_64' && 'x86_64' || 'arm64' }}" >> $GITHUB_ENV + python -m pip install aqtinstall + aqt install-qt mac desktop ${{ env.QT_VERSION }} clang_64 --archives qtbase -O "${{ github.workspace }}/qt" + # Recent macOS SDKs dropped the AGL framework that Qt 5.15.2's CMake config still links. + grep -rl "AGL" "$QT_PREFIX/lib/cmake" | xargs sed -i '' -e 's#;[^;"]*/AGL\.framework##g' -e 's#"[^;"]*/AGL\.framework;#"#g' + # Link against the exact Qt binaries the [gui] extra installs at runtime; + # they carry @rpath install names, so wheels need no absolute Qt paths. + python -m pip download --no-deps --only-binary=:all: --platform ${{ matrix.pyqt5_qt5_platform }} "PyQt5-Qt5==${{ env.PYQT5_QT5_VERSION }}" -d pyqt5-qt5 + unzip -q pyqt5-qt5/*.whl 'PyQt5/Qt5/lib/*' -d pyqt5-qt5 + for framework in "$QT_PREFIX"/lib/*.framework; do + name=$(basename "$framework" .framework) + binary="pyqt5-qt5/PyQt5/Qt5/lib/$name.framework/Versions/5/$name" + if [ -f "$binary" ]; then cp "$binary" "$framework/Versions/5/$name"; fi + done + rm -rf pyqt5-qt5 + + # Saved right away (not at job end) so a later failure doesn't discard the work. + - name: Save Qt + if: runner.os == 'macOS' && steps.cache-qt.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ github.workspace }}/qt + key: ${{ steps.cache-qt.outputs.cache-primary-key }} - name: Cache Xerces-C Installation - if: matrix.os != 'ubuntu-latest' + if: runner.os == 'macOS' id: cache-xerces - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ${{ github.workspace }}/xerces key: xerces-v${{ env.XERCES_VERSION }}-cpp${{ env.CPP_STANDARD }}-${{ - matrix.platform_id }} + matrix.arch }}-macos${{ env.MACOSX_DEPLOYMENT_TARGET }} - name: Build and Install Xerces-C if: - steps.cache-xerces.outputs.cache-hit != 'true' && matrix.os != - 'ubuntu-latest' + runner.os == 'macOS' && steps.cache-xerces.outputs.cache-hit != 'true' run: | git clone https://github.com/apache/xerces-c.git xerces-source git -C xerces-source checkout tags/v${{ env.XERCES_VERSION }} - cmake -B xerces-build -S xerces-source ${{ env.CMAKE_ARGS }} -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${{ github.workspace }}/xerces -DCMAKE_CXX_STANDARD=${{ env.CPP_STANDARD }} -DBUILD_SHARED_LIBS=OFF ${{ matrix.os == 'windows-latest' && '-DCMAKE_CXX_FLAGS_RELEASE="/MT"' || '-G Ninja -DCMAKE_CXX_FLAGS=-fPIC -DCMAKE_C_FLAGS=-fPIC -Dnetwork-accessor=socket -Dtranscoder=iconv' }} - cmake --build xerces-build --parallel $(nproc) --config Release --target install + cmake -G Ninja -B xerces-build -S xerces-source -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${{ github.workspace }}/xerces -DCMAKE_CXX_STANDARD=${{ env.CPP_STANDARD }} -DCMAKE_OSX_DEPLOYMENT_TARGET=${{ env.MACOSX_DEPLOYMENT_TARGET }} -DBUILD_SHARED_LIBS=OFF -DCMAKE_CXX_FLAGS=-fPIC -DCMAKE_C_FLAGS=-fPIC -Dnetwork-accessor=socket -Dtranscoder=iconv + cmake --build xerces-build --parallel --config Release --target install + + - name: Save Xerces-C Installation + if: + runner.os == 'macOS' && steps.cache-xerces.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ github.workspace }}/xerces + key: ${{ steps.cache-xerces.outputs.cache-primary-key }} - name: Cache Geant4 Installation - if: matrix.os != 'ubuntu-latest' + if: runner.os == 'macOS' id: cache-geant4 - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ${{ env.Geant4_DIR }} key: - geant4-v${{ env.GEANT4_VERSION }}-cpp${{ env.CPP_STANDARD }}-${{ - matrix.platform_id }} + geant4-v${{ env.GEANT4_VERSION }}-shared-qt${{ env.QT_VERSION + }}-pyqt5-qt5-${{ env.PYQT5_QT5_VERSION }}-cpp${{ env.CPP_STANDARD + }}-${{ matrix.arch }}-macos${{ env.MACOSX_DEPLOYMENT_TARGET }} - - name: Build and Install Geant4 (without datasets) + - name: Build and Install Geant4 (shared, Qt viewer, without datasets) if: - steps.cache-geant4.outputs.cache-hit != 'true' && matrix.os != - 'ubuntu-latest' + runner.os == 'macOS' && steps.cache-geant4.outputs.cache-hit != 'true' run: | - git clone https://github.com/Geant4/geant4.git ${{ github.workspace }}/geant4-source --depth 1 --branch v${{ env.GEANT4_VERSION }} - cmake -B geant4-build -S geant4-source ${{ env.CMAKE_ARGS }} -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${{ env.Geant4_DIR }} -DXERCESC_ROOT_DIR=${{ github.workspace }}/xerces -DCMAKE_CXX_STANDARD=${{ env.CPP_STANDARD }} -DGEANT4_USE_GDML=ON -DGEANT4_INSTALL_EXAMPLES=OFF -DGEANT4_INSTALL_DATA=OFF -DGEANT4_BUILD_TLS_MODEL=global-dynamic -DBUILD_STATIC_LIBS=ON -DBUILD_SHARED_LIBS=OFF ${{ matrix.os == 'windows-latest' && '-DCMAKE_CXX_FLAGS_RELEASE="/MT"' || '-G Ninja -DCMAKE_CXX_FLAGS=-fPIC -DCMAKE_C_FLAGS=-fPIC -DGEANT4_USE_SYSTEM_EXPAT=OFF' }} - cmake --build geant4-build --parallel $(nproc) --config Release --target install - - - name: Setup Python - uses: actions/setup-python@v5 + git clone https://github.com/Geant4/geant4.git geant4-source --depth 1 --branch v${{ env.GEANT4_VERSION }} + # Typo in Geant4 11.2.2's g4tools (fixed in later releases) that Xcode 16.3+ clang rejects. + sed -i '' 's/a_from\.m_barnch/a_from.m_branch/' geant4-source/source/externals/g4tools/include/tools/wroot/columns.icc + cmake -G Ninja -B geant4-build -S geant4-source -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${{ env.Geant4_DIR }} -DCMAKE_CXX_STANDARD=${{ env.CPP_STANDARD }} -DCMAKE_OSX_DEPLOYMENT_TARGET=${{ env.MACOSX_DEPLOYMENT_TARGET }} -DXERCESC_ROOT_DIR=${{ github.workspace }}/xerces -DGEANT4_USE_GDML=ON -DGEANT4_USE_QT=ON -DGEANT4_USE_QT3D=OFF -DCMAKE_PREFIX_PATH=$QT_PREFIX -DGEANT4_INSTALL_EXAMPLES=OFF -DGEANT4_INSTALL_DATA=OFF -DGEANT4_USE_SYSTEM_EXPAT=OFF -DGEANT4_USE_SYSTEM_ZLIB=ON -DBUILD_SHARED_LIBS=ON -DBUILD_STATIC_LIBS=OFF + cmake --build geant4-build --parallel --config Release --target install + rm -rf geant4-source geant4-build + + - name: Save Geant4 Installation + if: + runner.os == 'macOS' && steps.cache-geant4.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 with: - python-version: 3.12 + path: ${{ env.Geant4_DIR }} + key: ${{ steps.cache-geant4.outputs.cache-primary-key }} - - name: Build wheel - uses: pypa/cibuildwheel@v2.21.2 + # Geant4's Qt-linked libraries reference @rpath/Qt*.framework but carry no + # rpath to it, so delocate couldn't resolve (and then --exclude) them. + # relink_qt_wheel.py later swaps this build path for the PyQt5-Qt5 one. + - name: Add the Qt rpath to Geant4's Qt-linked libraries + if: runner.os == 'macOS' + run: | + for lib in "$Geant4_DIR"/lib/libG4*.dylib; do + otool -L "$lib" | grep -q "@rpath/Qt" || continue + otool -l "$lib" | grep -q "path $QT_PREFIX/lib " && continue + install_name_tool -add_rpath "$QT_PREFIX/lib" "$lib" + codesign --force --sign - "$lib" + echo "added Qt rpath: $(basename "$lib")" + done + + - name: Build wheels + uses: pypa/cibuildwheel@v2.22.0 env: - CIBW_TEST_COMMAND: - python -m pytest {project}/tests -vv --reruns 10 --reruns-delay 30 - --only-rerun "(?i)http|timeout|connection|socket|resolve" - CIBW_ENVIRONMENT: > - SKLEARN_SKIP_NETWORK_TESTS=1 SKLEARN_BUILD_PARALLEL=3 - CIBW_BUILD: cp${{ matrix.python }}-${{ matrix.platform_id }} - CIBW_ARCHS: all - CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux_image }} - CIBW_MANYLINUX_I686_IMAGE: ${{ matrix.manylinux_image }} - CIBW_TEST_SKIP: "*-macosx_arm64" # test on arm64 is not supported yet + CIBW_BUILD: cp38-* cp39-* cp310-* cp311-* cp312-* cp313-* + CIBW_SKIP: "*-musllinux_*" + CIBW_ARCHS: ${{ matrix.arch }} + CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 + CIBW_CONFIG_SETTINGS: cmake.define.GEANT4_PYTHON_APPLICATION_VISUALIZATION=ON + CIBW_TEST_EXTRAS: test,gui + CIBW_TEST_SKIP: "" + + CIBW_ENVIRONMENT_MACOS: > + MACOSX_DEPLOYMENT_TARGET=${{ env.MACOSX_DEPLOYMENT_TARGET }} + Geant4_DIR=${{ env.Geant4_DIR }} CMAKE_PREFIX_PATH=${{ env.QT_PREFIX + }} GEANT4_PYTHON_APPLICATION_DIR=${{ runner.temp }}/geant4-data ${{ + inputs.version && format('SETUPTOOLS_SCM_PRETEND_VERSION={0}', + inputs.version) || '' }} + CIBW_ENVIRONMENT_LINUX: > + GEANT4_PYTHON_APPLICATION_DIR=/tmp/geant4-data ${{ inputs.version && + format('SETUPTOOLS_SCM_PRETEND_VERSION={0}', inputs.version) || '' + }} + CIBW_BEFORE_ALL_LINUX: | + set -ex + dnf install -y qt5-qtbase-devel mesa-libGL-devel mesa-libGLU-devel python3 -m pip install cmake ninja git clone https://github.com/apache/xerces-c.git /tmp/xerces git -C /tmp/xerces checkout tags/v${{ env.XERCES_VERSION }} @@ -205,17 +239,46 @@ jobs: cmake --build /tmp/xerces/build --parallel $(nproc) --target install > /dev/null 2>&1 rm -rf /tmp/xerces git clone https://github.com/Geant4/geant4.git /tmp/geant4 --branch=v${{ env.GEANT4_VERSION }} --depth=1 - cmake -G Ninja -B /tmp/geant4/build -S /tmp/geant4 -DCMAKE_CXX_STANDARD=${{ env.CPP_STANDARD }} -DCMAKE_BUILD_TYPE=Release -DGEANT4_USE_GDML=ON -DGEANT4_INSTALL_EXAMPLES=OFF -DGEANT4_INSTALL_DATA=OFF -DGEANT4_BUILD_TLS_MODEL=global-dynamic -DCMAKE_CXX_FLAGS=-fPIC -DCMAKE_C_FLAGS=-fPIC -DGEANT4_USE_SYSTEM_EXPAT=OFF -DBUILD_STATIC_LIBS=ON -DBUILD_SHARED_LIBS=OFF + # global-dynamic TLS: Geant4's thread-locals otherwise exhaust the static TLS + # block when Python dlopen()s the extension ("cannot allocate memory in static TLS block"). + cmake -G Ninja -B /tmp/geant4/build -S /tmp/geant4 -DCMAKE_CXX_STANDARD=${{ env.CPP_STANDARD }} -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_LIBDIR=lib -DGEANT4_BUILD_TLS_MODEL=global-dynamic -DGEANT4_USE_GDML=ON -DGEANT4_USE_QT=ON -DGEANT4_USE_QT3D=OFF -DGEANT4_INSTALL_EXAMPLES=OFF -DGEANT4_INSTALL_DATA=OFF -DGEANT4_USE_SYSTEM_EXPAT=OFF -DBUILD_SHARED_LIBS=ON -DBUILD_STATIC_LIBS=OFF cmake --build /tmp/geant4/build --parallel $(nproc) --target install > /dev/null 2>&1 rm -rf /tmp/geant4 - CIBW_ENVIRONMENT_MACOS: > - MACOSX_DEPLOYMENT_TARGET=11.0 ${{ matrix.platform_id == - 'macosx_arm64' && 'SKIP_PYBIND11_STUBGEN=1' || '' }} - - name: Upload wheel + # Bundle Geant4 into the wheel but leave Qt out; tools/relink_qt_wheel.py + # then points the Qt-linked libraries at the PyQt5-Qt5 package. + CIBW_REPAIR_WHEEL_COMMAND_MACOS: > + rm -rf /tmp/delocated && delocate-wheel --require-archs + {delocate_archs} --exclude .framework/Versions/5/Qt -w + /tmp/delocated -v {wheel} && python -m pip install wheel && python + ${{ github.workspace }}/tools/relink_qt_wheel.py + /tmp/delocated/*.whl -w {dest_dir} + CIBW_REPAIR_WHEEL_COMMAND_LINUX: > + rm -rf /tmp/repaired && auditwheel repair -w /tmp/repaired {wheel} + --exclude libQt5Core.so.5 --exclude libQt5Gui.so.5 --exclude + libQt5Widgets.so.5 --exclude libQt5OpenGL.so.5 --exclude + libQt5PrintSupport.so.5 --exclude libQt5DBus.so.5 && python -m pip + install wheel && python /project/tools/relink_qt_wheel.py + /tmp/repaired/*.whl -w {dest_dir} + + # Runtime libraries PyQt5-Qt5 expects from the system, plus a virtual display. + CIBW_BEFORE_TEST_LINUX: > + dnf install -y xorg-x11-server-Xvfb mesa-dri-drivers mesa-libGL + fontconfig freetype libxkbcommon libxkbcommon-x11 xcb-util-wm + xcb-util-image xcb-util-keysyms xcb-util-renderutil dbus-libs + CIBW_TEST_COMMAND_MACOS: > + python -m pytest {project}/tests -vv --reruns 10 --reruns-delay 30 + --only-rerun "(?i)http|timeout|connection|socket|resolve" && python + {project}/tools/smoke_viewer.py + CIBW_TEST_COMMAND_LINUX: > + python -m pytest {project}/tests -vv --reruns 10 --reruns-delay 30 + --only-rerun "(?i)http|timeout|connection|socket|resolve" && + xvfb-run -a python {project}/tools/smoke_viewer.py + + - name: Upload wheels uses: actions/upload-artifact@v4 with: - name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} + name: cibw-wheels-${{ matrix.os }}-${{ matrix.arch }} path: wheelhouse/*.whl check-wheels-size: @@ -249,6 +312,8 @@ jobs: needs: [sdist, wheels, check-wheels-size] runs-on: ubuntu-latest if: github.event_name == 'release' && github.event.action == 'published' + permissions: + contents: write steps: - name: Setup Python @@ -267,6 +332,13 @@ jobs: with: password: ${{ secrets.PYPI_API_TOKEN }} + - name: Attach wheels to the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: > + gh release upload "${{ github.event.release.tag_name }}" dist/* --repo + "${{ github.repository }}" --clobber + publish-docker: name: Publish Docker image needs: [upload-pypi] diff --git a/.gitignore b/.gitignore index 09728090..0178deca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ _version.py +.DS_Store # Byte-compiled / optimized / DLL files __pycache__/ @@ -124,6 +125,7 @@ celerybeat.pid # Environments .env .venv +.venv-test/ env/ venv/ ENV/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 5410ab8a..6fb9cd01 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,10 +1,12 @@ cmake_minimum_required(VERSION 3.25...3.30) +# CMP0169 only exists from CMake 3.30; older versions already behave as OLD. +if (POLICY CMP0169) + cmake_policy(SET CMP0169 OLD) +endif () set(CMAKE_CXX_STANDARD 17) - set(AWKWARD_VERSION "v2.6.9") set(PYBIND11_VERSION "v2.13.6") -set(Geant4_VERSION "11.2.2") if (NOT DEFINED SKBUILD_PROJECT_NAME) # IDE @@ -14,6 +16,11 @@ endif () project(${SKBUILD_PROJECT_NAME} LANGUAGES CXX) set(PYTHON_MODULE_NAME _geant4_application) +set(VIS_MODULE_NAME _geant4_vis) +option( + GEANT4_PYTHON_APPLICATION_VISUALIZATION + "Build the interactive Geant4 Qt viewer as a separate, lazily imported module" + OFF) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) @@ -23,18 +30,26 @@ find_package( COMPONENTS Interpreter Development.Module REQUIRED) -# define geant4 components in a conditional way, "gdml static" if release build, -# "gdml" otherwise -set(GEANT4_COMPONENTS "gdml") -if (CMAKE_BUILD_TYPE STREQUAL "Release") - set(GEANT4_COMPONENTS "gdml static") +if (GEANT4_PYTHON_APPLICATION_VISUALIZATION) + # The viewer module and the physics module must share one Geant4 (its + # managers are singletons), which only works with shared libraries. + find_package(Geant4 11.2.2 CONFIG REQUIRED COMPONENTS gdml ui_all vis_all) + if (NOT Geant4_shared_FOUND) + message( + FATAL_ERROR + "GEANT4_PYTHON_APPLICATION_VISUALIZATION requires Geant4 built with shared libraries" + ) + endif () +else () + # "static" is optional: Geant4Config fails if it is required but only shared + # libraries are installed, while as an optional component it still selects + # the static libraries whenever they exist. + find_package( + Geant4 11.2.2 CONFIG REQUIRED + COMPONENTS gdml + OPTIONAL_COMPONENTS static) endif () -find_package( - Geant4 ${Geant4_VERSION} CONFIG - COMPONENTS ${GEANT4_COMPONENTS} - REQUIRED) - message(STATUS "Using Geant4 ${Geant4_VERSION} from ${Geant4_DIR}") message(STATUS "Using Python ${Python_VERSION} from ${Python_EXECUTABLE}") @@ -46,7 +61,9 @@ FetchContent_Declare( ) FetchContent_GetProperties(awkward-headers) if (NOT awkward-headers_POPULATED) - FetchContent_MakeAvailable(awkward-headers) + FetchContent_Populate(awkward-headers) + add_subdirectory(${awkward-headers_SOURCE_DIR} + ${awkward-headers_BINARY_DIR} EXCLUDE_FROM_ALL) endif () FetchContent_Declare( @@ -56,15 +73,42 @@ FetchContent_Declare( DOWNLOAD_EXTRACT_TIMESTAMP 1) FetchContent_GetProperties(pybind11) if (NOT pybind11_POPULATED) - FetchContent_MakeAvailable(pybind11) + FetchContent_Populate(pybind11) + add_subdirectory(${pybind11_SOURCE_DIR} ${pybind11_BINARY_DIR} + EXCLUDE_FROM_ALL) endif () +# Geant4 libraries that link Qt directly, or reach it through each other (the +# visualization drivers, UI sessions and vis manager). The physics module must +# not link any of them, so that importing the package never loads Qt. +set(GEANT4_QT_LIBRARIES + G4OpenGL + G4ToolsSG + G4interfaces + G4visQt3D + G4vis_management + G4FR + G4GMocren + G4RayTracer + G4Tree + G4VRML + G4visHepRep + G4OpenInventor + G4visVtk) +set(GEANT4_PHYSICS_LIBRARIES ${Geant4_LIBRARIES}) +foreach (lib IN LISTS GEANT4_QT_LIBRARIES) + list(REMOVE_ITEM GEANT4_PHYSICS_LIBRARIES Geant4::${lib} + Geant4::${lib}-static) +endforeach () + file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/src/geant4_application/src/*.cpp") file(GLOB_RECURSE PYTHON_SOURCES CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/src/python/*.cpp") +get_filename_component(GEANT4_PREFIX "${Geant4_DIR}/../../.." ABSOLUTE) + pybind11_add_module(${PYTHON_MODULE_NAME} MODULE ${SOURCES} ${PYTHON_SOURCES}) target_compile_definitions( @@ -81,23 +125,62 @@ target_include_directories( ${Geant4_INCLUDE_DIRS}) target_link_libraries( - ${PYTHON_MODULE_NAME} PRIVATE ${Geant4_LIBRARIES} awkward::layout-builder - pybind11::headers) + ${PYTHON_MODULE_NAME} PRIVATE ${GEANT4_PHYSICS_LIBRARIES} + awkward::layout-builder pybind11::headers) + +set(EXTENSION_MODULES ${PYTHON_MODULE_NAME}) + +if (GEANT4_PYTHON_APPLICATION_VISUALIZATION) + pybind11_add_module(${VIS_MODULE_NAME} MODULE + "${PROJECT_SOURCE_DIR}/src/vis/module.cpp") + # Leave out Geant4's Qt3D driver: the PyQt5-Qt5 wheel that provides Qt to + # published wheels ships no Qt3D modules. Not linking Geant4::G4visQt3D also + # drops its G4VIS_USE_QT3D define, so G4VisExecutive never references it. + # The OpenGL Qt viewer (/vis/open OGL) is unaffected. + set(GEANT4_VIS_LIBRARIES ${Geant4_LIBRARIES}) + list(REMOVE_ITEM GEANT4_VIS_LIBRARIES Geant4::G4visQt3D) + target_include_directories(${VIS_MODULE_NAME} + PRIVATE ${Geant4_INCLUDE_DIRS}) + target_link_libraries(${VIS_MODULE_NAME} PRIVATE ${GEANT4_VIS_LIBRARIES} + pybind11::headers) + # Homebrew may expose Qt 6 from its global include directory while this + # Geant4 installation was built with Qt 5. Put Qt 5's forwarding headers + # first so framework headers do not accidentally include their Qt 6 peers. + if (APPLE AND DEFINED Qt5Core_DIR) + get_filename_component(QT5_PREFIX "${Qt5Core_DIR}/../../.." ABSOLUTE) + target_include_directories(${VIS_MODULE_NAME} SYSTEM BEFORE + PRIVATE "${QT5_PREFIX}/include") + endif () + list(APPEND EXTENSION_MODULES ${VIS_MODULE_NAME}) +endif () -pybind11_extension(${PYTHON_MODULE_NAME}) -if (NOT MSVC AND NOT ${CMAKE_BUILD_TYPE} MATCHES Debug|RelWithDebInfo) - # Strip unnecessary sections of the binary on Linux/macOS - pybind11_strip(${PYTHON_MODULE_NAME}) +# Wheel repair tools (delocate/auditwheel) replace these with the bundled +# library locations; source installs keep finding Geant4 where it was built. Qt +# frameworks linked by @rpath (as when building against PyQt5-Qt5's Qt) also +# need their directory listed for delocate to resolve them. +set(EXTENSION_RPATH "${GEANT4_PREFIX}/lib") +if (APPLE AND DEFINED QT5_PREFIX) + list(APPEND EXTENSION_RPATH "${QT5_PREFIX}/lib") endif () -set_target_properties( - ${PYTHON_MODULE_NAME} - PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/${PROJECT_NAME} - CXX_VISIBILITY_PRESET "hidden" - CUDA_VISIBILITY_PRESET "hidden") +foreach (module IN LISTS EXTENSION_MODULES) + pybind11_extension(${module}) + if (NOT MSVC AND NOT ${CMAKE_BUILD_TYPE} MATCHES Debug|RelWithDebInfo) + # Strip unnecessary sections of the binary on Linux/macOS + pybind11_strip(${module}) + endif () + set_target_properties( + ${module} + PROPERTIES LIBRARY_OUTPUT_DIRECTORY + ${PROJECT_BINARY_DIR}/${PROJECT_NAME} + CXX_VISIBILITY_PRESET "hidden" + CUDA_VISIBILITY_PRESET "hidden" + BUILD_RPATH "${EXTENSION_RPATH}" + INSTALL_RPATH "${EXTENSION_RPATH}") +endforeach () install( - TARGETS ${PYTHON_MODULE_NAME} + TARGETS ${EXTENSION_MODULES} LIBRARY DESTINATION ${PROJECT_NAME} RUNTIME DESTINATION ${PROJECT_NAME} ARCHIVE DESTINATION ${PROJECT_NAME}) diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 00000000..5b321f8b --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,214 @@ +# Installation + +Prebuilt wheels bundle a compiled Geant4 11.2.2, so on supported platforms you +**do not need to install Geant4 (C++) yourself**. Building from source still +requires a Geant4 installation. + +| Platform | Python | Prebuilt wheel | Qt viewer (`[gui]`) | +| ---------------------- | ---------- | -------------- | --------------------------------- | +| macOS, Apple Silicon | 3.8 – 3.13 | yes | yes | +| macOS, Intel | 3.8 – 3.13 | yes | yes | +| Linux x86_64 | 3.8 – 3.13 | yes | yes (needs a desktop with OpenGL) | +| Linux aarch64, Windows | — | no | build from source | + +## Install a prebuilt wheel + +### From PyPI + +```bash +pip install "geant4-python-application[gui]" +``` + +Leave out `[gui]` for a physics-only install without Qt: + +```bash +pip install geant4-python-application +``` + +### From a GitHub release + +Each +[GitHub release](https://github.com/lobis/geant4-python-application/releases) +from 0.1.0 on also has the wheels attached. pip picks the right wheel for your +platform and Python version; replace `` with the release tag (e.g. +`v0.1.0`): + +```bash +pip install "geant4-python-application[gui]" \ + --find-links https://github.com/lobis/geant4-python-application/releases/expanded_assets/ +``` + +### From a CI build (unreleased changes) + +To try changes that are not released yet, use the wheels that each run of the +[Wheels workflow](https://github.com/lobis/geant4-python-application/actions/workflows/wheels.yaml) +keeps as downloadable artifacts for 90 days. Pick a successful run and download +the artifact for your platform: `cibw-wheels-ubuntu-latest-x86_64` (Linux), +`cibw-wheels-macos-15-arm64` (Apple Silicon) or +`cibw-wheels-macos-15-intel-x86_64` (Intel Mac). From the run page this needs a +GitHub login; with the [GitHub CLI](https://cli.github.com) (`gh auth login` +first): + +```bash +gh run download --repo lobis/geant4-python-application \ + -n cibw-wheels-ubuntu-latest-x86_64 -D ./g4-wheels +pip install "geant4-python-application[gui]" --find-links ./g4-wheels +``` + +`--find-links` takes the package from the folder and its dependencies (including +Qt for `[gui]`) from PyPI. + +### What the `gui` extra does + +The interactive viewer is a separate module that only `Application.visualize()` +loads. Importing the package and running simulations never loads Qt, so headless +machines, containers and clusters need neither Qt nor OpenGL. + +The `gui` extra installs Qt from the `PyQt5-Qt5` package; no Homebrew, conda or +system Qt is needed. On Linux, Qt still relies on the system's OpenGL and X11 +libraries, which desktop installations already have. Minimal installations may +need them added; the CI tests run on AlmaLinux with: + +```bash +dnf install mesa-libGL fontconfig freetype libxkbcommon libxkbcommon-x11 \ + xcb-util-wm xcb-util-image xcb-util-keysyms xcb-util-renderutil dbus-libs +``` + +On Debian/Ubuntu install the equivalent packages (e.g. `libgl1`, +`libxkbcommon-x11-0` and the `libxcb-*` utility libraries). + +`Application.visualization_available()` reports whether the viewer can be +opened, without loading Qt; otherwise `app.visualize()` raises a `RuntimeError` +explaining what is missing. Scripts that open the viewer by default (e.g. +`b1.py`) can run headless with `--batch`. + +## Build from source + +Needed on platforms without wheels, and for development. Building requires a +Geant4 installation. + +### 1. Install Geant4 + +Via conda: + +```bash +conda install -c conda-forge geant4=11.2.2 +``` + +You may also need build tools, e.g. `conda install cmake ninja` (plus +`gxx_linux-64` on Linux). + +Or from source, matching the CI configuration: + +```bash +git clone https://github.com/Geant4/geant4.git ./geant4-source --depth 1 --branch v11.2.2 + +cmake -B ./geant4-build -S ./geant4-source \ + -DCMAKE_INSTALL_PREFIX=./geant4-install \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_STANDARD=17 \ + -DGEANT4_USE_GDML=ON \ + -DGEANT4_INSTALL_EXAMPLES=OFF \ + -DGEANT4_INSTALL_DATA=OFF \ + -DGEANT4_BUILD_TLS_MODEL=global-dynamic \ + -DBUILD_STATIC_LIBS=ON \ + -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_CXX_FLAGS=-fPIC \ + -DCMAKE_C_FLAGS=-fPIC \ + -DGEANT4_USE_SYSTEM_EXPAT=OFF + +cmake --build ./geant4-build --parallel $(nproc) --config Release --target install +``` + +If `xerces-c` is missing, install it from your package manager +(`apt-get install libxerces-c-dev`) or build it from source: + +```bash +git clone https://github.com/apache/xerces-c.git ./xerces-source +git -C ./xerces-source checkout tags/v3.2.5 + +cmake -B ./xerces-build -S ./xerces-source \ + -DCMAKE_INSTALL_PREFIX=./xerces-install \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_STANDARD=17 \ + -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_CXX_FLAGS=-fPIC \ + -DCMAKE_C_FLAGS=-fPIC \ + -Dnetwork-accessor=socket \ + -Dtranscoder=iconv + +cmake --build ./xerces-build --parallel $(nproc) --config Release --target install +``` + +### 2. Build the package + +```bash +pip install git+https://github.com/lobis/geant4-python-application +``` + +or from a clone: + +```bash +git clone https://github.com/lobis/geant4-python-application.git +cd geant4-python-application +pip install . +``` + +The provided `Dockerfile` also documents the required dependencies: + +```bash +docker build -t geant4-python-application . +``` + +### Qt viewer in source builds + +Off by default. It needs a Geant4 built as **shared** libraries with Qt and +OpenGL (e.g. conda-forge `geant4` with `qt[yes]`/`opengl-x11[yes]`); the viewer +then uses that Geant4's Qt: + +```bash +pip install . -Ccmake.define.GEANT4_PYTHON_APPLICATION_VISUALIZATION=ON +``` + +### Building a relocatable macOS wheel yourself + +Bundles Geant4, Xerces-C, Expat and other non-system libraries into a wheel that +takes Qt from `PyQt5-Qt5`, like the published wheels: + +```bash +PYTHON="$CONDA_PREFIX/bin/python" \ +Geant4_DIR=/usr/local/lib/cmake/Geant4 \ +tools/build_macos_self_contained.sh +pip install "$(ls dist/self-contained/*.whl)[gui]" +``` + +The wheel only installs on the macOS version (and newer) that the bundled Geant4 +was built for. The published wheels are built by +`.github/workflows/wheels.yaml`, targeting macOS 11 and manylinux_2_28. + +## Data files + +Geant4's physics data files (~2 GB) are not part of the wheel. They download +automatically the first time a simulation runs, unless an existing Geant4 +installation (found via `geant4-config`) already provides the exact dataset +versions, in which case those are used. + +```bash +python -c "import geant4_python_application as g4; print(g4.data_directory())" +``` + +- Default location, shared by all your Python environments so the data downloads + only once: `~/Library/Application Support/geant4_python_application` on macOS, + `~/.local/share/geant4_python_application` on Linux. +- Override the persistent location by setting `GEANT4_PYTHON_APPLICATION_DIR` + before import, or calling `application_directory(path)` before the application + initializes. +- Use a temporary directory instead: `application_directory(temp=True)`. If the + OS partially cleans it up mid-run, delete it manually and let the application + recreate it. +- Interrupted downloads resume, failed downloads raise an error, and each + archive's MD5 checksum is verified before it is extracted. +- Uninstalling the package does **not** remove downloaded data files — remove + them manually. +- For cluster batch jobs, point `application_directory` at a shared location to + avoid redundant downloads across jobs. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..84dcaf15 --- /dev/null +++ b/NOTICE @@ -0,0 +1,21 @@ +geant4-python-application +Copyright (c) 2023, Luis Antonio Obis Aparicio and contributors. +Licensed under the BSD 3-Clause License; see LICENSE. + +The prebuilt wheels bundle the following third-party libraries. Their licence +texts are in the licenses/ directory (installed in the wheel's .dist-info). + +- Geant4 11.2.2 (https://geant4.web.cern.ch): Geant4 Software License, + licenses/Geant4-LICENSE. +- CLHEP, as shipped inside Geant4 (G4clhep): GNU LGPL v3, + licenses/CLHEP-COPYING.LESSER and licenses/CLHEP-COPYING. It is bundled as a + separate shared library that can be replaced. +- Expat, as shipped inside Geant4 (G4expat): MIT, licenses/Expat-COPYING. +- PTL, as shipped inside Geant4 (G4ptl): MIT, licenses/PTL-LICENSE. +- zlib, as shipped inside Geant4 (G4zlib, Linux wheels): zlib License, + licenses/zlib-LICENSE. +- Apache Xerces-C++ 3.2.5 (https://xerces.apache.org): Apache License 2.0, + licenses/Xerces-C-LICENSE and licenses/Xerces-C-NOTICE. + +Qt is not bundled. The optional [gui] extra installs it from the PyQt5-Qt5 +package, which carries its own licence (LGPL v3). diff --git a/PYTHON_DEVELOPER_GUIDE.md b/PYTHON_DEVELOPER_GUIDE.md new file mode 100644 index 00000000..3cdc6892 --- /dev/null +++ b/PYTHON_DEVELOPER_GUIDE.md @@ -0,0 +1,1631 @@ +# Geant4 with Python: a beginner's developer guide + +This guide teaches the Geant4 concepts used by `geant4-python-application` and +shows how to build small simulations in Python. It is intentionally practical: +the Python package provides a high-level application, while the Geant4 kernel +does the particle transport in native C++. + +It is not a complete replacement for the official Geant4 Application Developer +Guide. Use that guide for detailed physics, geometry, and toolkit reference. Use +this guide for the Python interface and this repository's supported workflow. + +## 1. The simulation idea + +A Geant4 simulation normally has these pieces: + +1. **Geometry**: the world, solids, logical volumes, materials, and placements. +2. **Physics**: the processes that act on particles. +3. **Primary generator**: the particles, energies, positions, and directions + injected into the geometry. +4. **Run and event actions**: code that runs at run/event boundaries. +5. **Scoring**: the quantities extracted from steps, tracks, or volumes. + +In this Python package, geometry is supplied as GDML, physics and generators are +configured through `Application`, and the built-in native actions record events, +tracks, and steps into an Awkward Array. + +The normal lifecycle is: + +```text +create Application -> configure -> initialize -> BeamOn/run -> analyze output +``` + +## 2. Installation + +The package requires Python and a compatible Geant4 installation. From the +repository root: + +```bash +pip install . +``` + +For development, install Geant4 first. The repository's README contains a +source-build example. Your Geant4 installation should include the data files +needed by the physics lists. Check the installation with: + +```bash +python - <<'PY' +import geant4_python_application as g4 + +print("Geant4:", g4.geant4_version) +print("MT available:", g4.Application.multithreading_available()) +print("Physics lists:", g4.Application.available_physics_lists()[:5]) +PY +``` + +## 3. Your first simulation + +Create `first_simulation.py`: + +```python +import geant4_python_application as g4 + +with g4.Application(gdml=g4.basic_gdml, seed=137) as app: + events = app.run(10) + +print(events.fields) +print("number of events:", len(events)) +print(events[:2]) +``` + +`basic_gdml` is a small air world containing a water box. The context manager +starts and stops the worker process safely. `run(10)` transports ten events. + +The result is an `awkward.Array`, which is useful because each event can have a +different number of tracks, steps, and primary particles. + +## 4. Choosing the physics list + +A physics list selects the electromagnetic, decay, hadronic, optical, and other +processes used during transport: + +```python +import geant4_python_application as g4 + +print(g4.Application.available_physics_lists()) + +with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT") as app: + events = app.run(100) +``` + +`custom` is the package's built-in list. Reference lists such as `FTFP_BERT` +come from Geant4. Do not compare results from different lists as though they +were the same physical model. + +Optional constructors can be added before initialization: + +```python +with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT") as app: + app.add_physics("G4EmDNAPhysics_option2") + events = app.run(100) +``` + +## 5. Primary particles + +The convenient generator API configures a particle gun or GPS source: + +```python +with g4.Application(gdml=g4.basic_gdml) as app: + app.generator.use_gps().particle("gamma").energy(2, "MeV") + app.generator.position(0, 0, -10, "cm").direction(0, 0, 1) + events = app.run(1000) +``` + +The command interface exposes ordinary Geant4 UI commands: + +```python +with g4.Application(gdml=g4.basic_gdml) as app: + app.commands( + [ + "/gun/particle e-", + "/gun/energy 10 MeV", + "/gun/position 0 0 -10 cm", + "/gun/direction 0 0 1", + ] + ) + events = app.run(100) +``` + +For advanced GPS distributions, use `/gps/...` commands through +`app.generator.commands(...)`. + +## 6. Geometry with GDML + +This package does not expose arbitrary Python subclasses of +`G4VUserDetectorConstruction`. Instead, provide a GDML string or file. A minimal +GDML detector has: + +- `` constants +- `` or references to Geant4 NIST materials +- `` +- `` containing logical volumes and physical placements +- a `` that identifies the world volume + +The built-in example uses NIST materials: + +```xml + +``` + +Run your own GDML file like this: + +```python +from pathlib import Path +import geant4_python_application as g4 + +gdml = Path("detector.gdml").read_text() + +with g4.Application(gdml=gdml) as app: + print(app.detector.logical_volumes) + print(app.detector.physical_volumes) + events = app.run(100) +``` + +Inspect geometry before running: + +```python +with g4.Application(gdml=gdml) as app: + print(app.detector.materials) + print(app.detector.logical_volumes) + print(app.detector.physical_volumes) + app.detector.check_overlaps() +``` + +The exact names used for scoring and sensitive volumes are the GDML physical or +logical names, so inspect them rather than guessing. + +## 7. Fields and sensitive volumes + +Uniform global fields can be assigned before the run: + +```python +with g4.Application(gdml=gdml) as app: + app.detector.magnetic_field = (0.0, 0.0, 1.5) # tesla + app.detector.electric_field = (0.0, 0.0, 0.5) # kV/cm + events = app.run(100) +``` + +To focus on a detector volume's steps: + +```python +with g4.Application(gdml=gdml) as app: + app.detector.sensitive_volumes = {"gasVolume"} + events = app.run(100) +``` + +This does not create a custom Python `ProcessHits` implementation. The package +records native Geant4 steps and provides Python-side filtering and scoring. + +## 8. Understanding the event output + +Common fields include event IDs, primary information, tracks, and steps: + +```python +print(events.fields) +print(events.id) +print(events.track.fields) +print(events.track.step.fields) +``` + +Because this is a jagged structure, use Awkward operations rather than assuming +every event has the same number of tracks: + +```python +import awkward as ak + +track_counts = ak.num(events.track.id, axis=1) +print(track_counts) +``` + +The default event fields can be reduced for smaller output with: + +```python +with g4.Application(gdml=g4.basic_gdml) as app: + app.set_event_fields({"run", "id", "track_id", "step_energy", "step_volume"}) + events = app.run(100) +``` + +## 9. Scoring energy deposition + +Post-run scoring is often the easiest way to start: + +```python +import awkward as ak +import geant4_python_application as g4 + +with g4.Application(gdml=g4.basic_gdml) as app: + app.command("/gun/particle e-") + app.command("/gun/energy 10 MeV") + events = app.run(100) + +edep = g4.Scoring.energy_deposit(events) +print("energy per event in keV:", ak.to_numpy(edep)) + +edep_box = g4.Scoring.energy_deposit(events, volume="box") +mesh, edges = g4.Scoring.energy_mesh(events, bins=(20, 20, 20)) +``` + +For native Geant4 scoring meshes: + +```python +with g4.Application(gdml=g4.basic_gdml) as app: + mesh = g4.NativeScoringMesh(app, name="dose") + mesh.box(half_size=(250, 250, 250), bins=(20, 20, 20), unit="mm") + app.run(1000) + mesh.dump("dose.out") +``` + +## 10. Saving results + +```python +from pathlib import Path +from geant4_python_application import io + +io.events_to_parquet(events, Path("events.parquet")) +``` + +ROOT output is available when `uproot` is installed: + +```python +io.events_to_root(events, "events.root") +``` + +## 11. Visualization + +Visualization requires installing/building the package with Qt/OpenGL support: + +```bash +pip install . -Ccmake.define.GEANT4_PYTHON_APPLICATION_VISUALIZATION=ON +``` + +Then: + +```python +with g4.Application(gdml=g4.basic_gdml) as app: + app.command("/gun/particle gamma") + app.command("/gun/energy 1 MeV") + app.visualize() +``` + +Visualization is for inspecting geometry and tracks; production runs should +usually be performed in batch mode without the viewer. + +## 12. Multithreading and MPI + +Use native Geant4 CPU multithreading when the linked Geant4 was built with +multithreading: + +```python +with g4.Application(gdml=g4.basic_gdml, n_threads=8) as app: + events = app.run(100000) +``` + +Check availability with: + +```python +print(g4.Application.multithreading_available()) +``` + +For MPI, launch multiple Python processes and split the event count by rank: + +```python +from geant4_python_application import distributed as dist + +rank, size = dist.get_rank_size() +_, count = dist.split_count(100000, rank, size) + +with g4.Application(gdml=g4.basic_gdml, seed=1000 + rank) as app: + events = app.run(count) + g4.io.events_to_parquet(events, f"events_rank_{rank}.parquet") +``` + +Run it with: + +```bash +mpiexec -n 4 python mpi_run.py +``` + +MPI splitting and file merging are intentionally left to the application. Use +unique seeds and unique output files for each rank. + +## 13. Reproducibility and validation + +Set a seed for repeatable runs: + +```python +with g4.Application(gdml=g4.basic_gdml, seed=1234) as app: + events = app.run(1000) +``` + +Results can still vary with different Geant4 versions, physics lists, thread +counts, platforms, and random-engine behavior. Validate a simulation by checking +geometry overlaps, inspecting particle/energy distributions, and running enough +events for the statistical uncertainty to be meaningful. + +## 14. What this interface does not currently expose + +The current package is a high-level Python application rather than a complete +Python binding for every Geant4 class. It does not currently provide arbitrary +Python subclasses of detector construction, sensitive detectors, physics +processes, fields, or action classes. It also does not provide CUDA/GPU +transport. For those use cases, use native C++ Geant4 or a lower-level binding +such as `geant4_pybind`. + +## 15. A recommended learning path + +Work through these stages in order: + +1. Run `basic_gdml` with ten events. +2. Change the particle, energy, position, and direction. +3. Inspect `events.fields`, tracks, and steps. +4. Score energy deposition in a named volume. +5. Edit or generate a GDML detector. +6. Add fields and optical physics. +7. Save results to Parquet and analyze them with NumPy/Awkward. +8. Increase event counts and enable Geant4 multithreading. +9. Use MPI splitting for independent distributed jobs. + +The repository's numbered examples in `examples/geant4-examples/` correspond to +these topics and are useful as runnable companion exercises. + +# Part II - Building a complete application + +## 16. The application state machine + +The Python `Application` is a thin controller around a native Geant4 +application. It starts a child process, sends configuration calls to the native +application, and returns data to the parent Python process. This design keeps +the Geant4 kernel isolated and means that configuration must happen in the order +expected by Geant4. + +The useful states are: + +| State | What you can do | +| ----------- | ----------------------------------------------------------------------------- | +| Created | Set Python constructor options such as GDML, physics, seed, and thread count. | +| Started | Configure the manager, physics, detector, and generator. | +| Initialized | Inspect the initialized detector and run events. | +| Running | Geant4 transports events and records native actions. | +| Finished | Analyze or persist the returned Awkward Array in Python. | + +Most configuration must occur before the first call to `run`. For example, +physics constructors cannot be added after initialization: + +```python +with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT") as app: + app.add_physics("G4EmExtraPhysics") + app.detector.sensitive_volumes = {"box"} + app.initialize() + events = app.run(100) +``` + +Calling `run` automatically initializes a correctly configured application, so +explicit `initialize()` is optional unless you want to validate setup first. + +## 17. A complete reproducible example + +The following example is a good template for new projects: + +```python +from pathlib import Path + +import awkward as ak +import geant4_python_application as g4 + + +def main(): + output = Path("events.parquet") + + with g4.Application( + gdml=g4.basic_gdml, + physics="FTFP_BERT", + seed=2025, + n_threads=4, + ) as app: + app.set_event_fields( + { + "run", + "id", + "track_id", + "track_particle", + "step_energy", + "step_volume", + "step_position", + } + ) + app.generator.particle("gamma").energy(2, "MeV") + app.generator.position(0, 0, -10, "cm").direction(0, 0, 1) + app.detector.sensitive_volumes = {"box"} + events = app.run(10_000) + + deposited = g4.Scoring.energy_deposit(events, volume="box") + print("events:", len(events)) + print("mean deposited energy [keV]:", float(ak.mean(deposited))) + g4.io.events_to_parquet(events, output) + + +if __name__ == "__main__": + main() +``` + +Keep the simulation and analysis stages conceptually separate. The simulation +should produce a stable event record; analysis should be rerunnable without +transporting particles again. + +## 18. Units, coordinates, and naming + +Geant4 is unit-aware internally. In this interface, generator and field helper +methods take explicit unit strings, while GDML also declares units on numeric +values. Never silently mix millimetres, centimetres, metres, or tesla. + +```python +app.generator.position(0, 0, -10, "cm") +app.generator.energy(5, "MeV") +app.detector.magnetic_field = (0, 0, 1.0) # tesla +``` + +Positions in the event output and Python scoring mesh are expressed in +millimetres, and deposited energies are expressed in keV. Volume names are +case-sensitive strings originating in GDML. + +A useful naming convention is: + +```text +World world physical volume +detectorVolume logical volume +detector physical placement +``` + +Use unique, descriptive names. A name that is convenient in a GDML file is also +the name you will use later for filtering, scoring, and diagnostics. + +## 19. Geometry design with GDML + +### 19.1 The geometry hierarchy + +The hierarchy is: + +```text +solid -> logical volume -> physical volume -> world +``` + +A solid describes shape and dimensions. A logical volume combines a solid and a +material. A physical volume places a logical volume inside its mother. The world +is the top-level physical geometry used by the setup. + +### 19.2 A small custom detector + +Save this as `detector.gdml` and pass its text to the application: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +Then validate and run: + +```python +from pathlib import Path +import geant4_python_application as g4 + +gdml = Path("detector.gdml").read_text() +with g4.Application(gdml=gdml) as app: + print(app.detector.materials) + print(app.detector.physical_volumes) + app.detector.check_overlaps() + events = app.run(100) +``` + +### 19.3 Overlaps and navigation errors + +Overlapping daughter volumes can cause physically incorrect navigation. Check +overlaps after loading a detector and before large production runs. If a track +behaves unexpectedly, inspect the volume names, placement coordinates, mother +volume, and units first. + +## 20. Materials and optical properties + +The repository's GDML examples use Geant4 NIST material names such as `G4_AIR`, +`G4_WATER`, and `G4_Si`. This is the simplest way to begin. For a custom +material, define its density and composition in GDML according to the Geant4 +GDML schema. + +Optical transport needs both optical physics and optical material properties. +For example, a material that should transmit photons needs a refractive index +table. Merely setting `optical=True` does not invent optical properties: + +```python +from geant4_python_application import optical as gopt + +with g4.Application(gdml=gopt.optical_water_gdml, optical=True) as app: + app.commands(gopt.scintillation_commands(yield_factor=1.0)) + events = app.run(1_000) +``` + +Inspect the output particle field to distinguish optical photons from the +primary particle and other secondaries. + +## 21. Particle sources in detail + +### 21.1 Particle-gun workflow + +Use a particle gun when every event has a simple, controlled primary: + +```python +with g4.Application(gdml=g4.basic_gdml) as app: + app.generator.particle("proton") + app.generator.energy(100, "MeV") + app.generator.position(0, 0, -20, "cm") + app.generator.direction(0, 0, 1) + events = app.run(100) +``` + +### 21.2 General Particle Source + +Use GPS commands for distributions in position, angle, or energy: + +```python +with g4.Application(gdml=g4.basic_gdml) as app: + app.generator.use_gps() + app.generator.commands( + [ + "/gps/particle gamma", + "/gps/ene/type Mono", + "/gps/ene/mono 1 MeV", + "/gps/pos/type Point", + "/gps/pos/centre 0 0 -10 cm", + "/gps/direction 0 0 1", + ] + ) + events = app.run(100) +``` + +### 21.3 Event-by-event primary arrays + +For a predefined beam, provide an Awkward Array containing primary fields. This +is useful when the source comes from a file or another simulation: + +```python +import awkward as ak + +primaries = ak.Array( + { + "particle": ["gamma", "gamma", "e-"], + "energy": [1.0, 2.0, 5.0], + "position": {"x": [0, 0, 0], "y": [0, 0, 0], "z": [-10, -10, -10]}, + "direction": {"x": [0, 0, 0], "y": [0, 0, 0], "z": [1, 1, 1]}, + } +) + +with g4.Application(gdml=g4.basic_gdml) as app: + events = app.run(primaries) +``` + +Test array-driven primaries in serial mode first. The repository currently +documents limitations for this input path in multithreaded runs. + +## 22. Physics choices and statistical thinking + +A simulation result is determined by geometry, materials, source, physics list, +production cuts, random seed, and event count. Changing any of these changes the +result or its interpretation. + +Use a small run for functional validation, then a larger run for physics: + +```python +for n_events in (1, 10, 1_000, 100_000): + with g4.Application(gdml=g4.basic_gdml, seed=100 + n_events) as app: + events = app.run(n_events) + print(n_events, len(events)) +``` + +Do not interpret a single Monte Carlo event as a measurement. Report the sample +size and an uncertainty or confidence interval for the quantity of interest. + +## 23. Tracking, steps, and secondaries + +An event contains primary vertices and can contain many tracks. A track is +advanced through a sequence of steps. A step can end because of geometry, a +physics interaction, a field integration limit, or another tracking condition. + +Useful questions to ask in analysis are: + +```python +print(events.track.particle) +print(events.track.creator_process) +print(events.track.step.volume) +print(events.track.step.process) +``` + +For example, select positive-energy steps and inspect their volumes: + +```python +hits = events.hits() +detector_hits = hits[hits.volume == "box"] +``` + +The exact Awkward slicing expression can depend on the nesting shape, so begin +with `events.fields`, `events.track.fields`, and `events.track.step.fields`. + +## 24. Actions and callbacks in this package + +Native C++ actions are installed by the package and record run, event, track, +and step data. Python callbacks are an offline analysis layer: + +```python +def on_event(event): + print("event", event.id) + + +with g4.Application(gdml=g4.basic_gdml) as app: + events = app.run_with_callbacks(10, on_event=on_event) +``` + +Callbacks do not replace native Geant4 `G4UserRunAction`, `G4UserEventAction`, +or `G4VSensitiveDetector` subclasses. They execute after transport over the +returned records. This makes them convenient for Python analysis, but not +suitable for changing transport behavior during a step. + +## 25. Control commands and diagnostics + +Any command accepted by the configured Geant4 application can be sent through +`app.command` or `app.commands`: + +```python +with g4.Application(gdml=g4.basic_gdml) as app: + print(app.list_commands("/run")) + app.command("/run/verbose 1") + events = app.run(1) +``` + +Use verbose output sparingly. Large verbose logs can dominate runtime and make +it harder to see the actual error. Prefer small event counts while debugging. + +## 26. Visualization as a debugging tool + +The Qt viewer is most useful for answering geometry questions: is the world +present, is the target where expected, and does the primary start in the world? +Build with visualization enabled, then run a few events. Keep visualization +separate from production output and do not use it to estimate physics results. + +## 27. Production run design + +For a production campaign: + +1. Freeze the GDML, physics list, package version, and Geant4 version. +2. Validate the geometry and source with a one-event visualization run. +3. Run a small serial reference sample. +4. Compare a multithreaded sample with the same configuration. +5. Split large jobs into deterministic MPI ranks with unique seeds. +6. Write one output file per rank. +7. Merge and analyze outside the transport process. + +Do not write all ranks to the same file unless the output format and writer are +explicitly designed for concurrent access. + +# Part III - Advanced topics and boundaries + +## 28. Multithreading in detail + +`n_threads=0` selects the serial Geant4 run manager. A positive value selects +the multithreaded run manager and requests that many worker threads: + +```python +with g4.Application(gdml=g4.basic_gdml, n_threads=8) as app: + events = app.run(100_000) +``` + +The linked Geant4 library must have been compiled with multithreading. More +threads are not always faster: geometry initialization, memory pressure, output +construction, and CPU oversubscription can reduce the gain. + +Benchmark with the same event count and seed policy. Validate event counts and +aggregate quantities, but do not require event ordering to be identical across +thread counts. + +## 29. MPI and hybrid execution + +MPI creates independent Python/Geant4 processes. Each rank should construct its +own application and run only its assigned events. A common hybrid strategy is +one MPI rank per node with several Geant4 worker threads per rank: + +```python +rank, size = dist.get_rank_size() +_, count = dist.split_count(1_000_000, rank, size) + +with g4.Application( + gdml=g4.basic_gdml, + n_threads=8, + seed=10_000 + rank, +) as app: + events = app.run(count) + g4.io.events_to_parquet(events, f"events_{rank:04d}.parquet") +``` + +This package provides rank discovery and event splitting, not an automatic +Geant4 MPI manager, collective reduction, or file merger. + +## 30. Performance and memory + +Recording every step for every track can produce large nested arrays. For large +runs: + +- request only the event fields needed for analysis; +- run in batches and write one file per batch; +- avoid Python callbacks for every step unless necessary; +- use native scoring meshes when a full step record is not needed; +- benchmark thread counts on the target machine. + +The `Application` worker process also means that event data is transferred back +to the parent Python process. Treat that transfer as part of the cost of a +high-volume run. + +## 31. Testing a simulation + +A useful test suite has three layers: + +### Configuration tests + +Check that the GDML loads, expected volumes exist, overlaps are absent, and the +selected physics list is available. + +### Transport smoke tests + +Run one to ten events and assert that the result contains the expected fields +and event count. + +### Physics regression tests + +Run a fixed seed and compare distributions or aggregate values with tolerances. +Avoid exact event-by-event comparisons when changing thread count or platform. + +```python +def test_smoke(): + with g4.Application(gdml=g4.basic_gdml, seed=7) as app: + events = app.run(5) + assert len(events) == 5 + assert "id" in events.fields +``` + +## 32. Common failure modes + +**The application says the data files are missing.** Install the Geant4 datasets +and verify the environment used by Python is the same environment used to +install Geant4. + +**`n_threads` fails.** Check `g4.Application.multithreading_available()` and use +`n_threads=0` with a serial Geant4 build. + +**A volume cannot be found.** Print `app.detector.logical_volumes` and +`app.detector.physical_volumes`; GDML names are case-sensitive. + +**Energy deposition is empty.** Confirm that step fields were requested, that +the particle enters the volume, and that the volume filter uses the actual +physical volume name. + +**The detector overlaps.** Check half-length versus full-length conventions, +mother volume dimensions, placement coordinates, and units. + +**The run is unexpectedly slow.** Reduce fields, disable verbose output, use a +smaller diagnostic run, and benchmark thread counts. Optical photon tracking can +be substantially more expensive than the primary transport. + +## 33. Mapping native Geant4 concepts to this Python interface + +| Native Geant4 concept | Python-interface equivalent | +| ------------------------------------------------ | ------------------------------------------------------- | +| `G4RunManager` | `Application` and its native manager setup | +| `G4VUserDetectorConstruction` | GDML supplied to `Application` | +| `G4VUserPhysicsList` | `physics="..."` and curated `add_physics(...)` | +| `G4VUserPrimaryGeneratorAction` | `app.generator` or `/gun` and `/gps` commands | +| `G4RunManager::BeamOn` | `app.run(n_events)` | +| `G4UserRunAction` / event / track / step actions | Built-in native recording plus offline Python callbacks | +| Sensitive detector / hits | Recorded steps, `events.hits()`, and Python scoring | +| Command-based scoring | `NativeScoringMesh` | +| Geant4 visualization manager | `app.visualize()` with Qt/OpenGL build support | +| Geant4 MT run manager | `n_threads=N` | +| MPI job splitting | `distributed.get_rank_size()` and `split_count()` | + +## 34. Scope boundary + +This guide is deliberately detailed about the supported interface, but the +interface is not a one-to-one Python binding of the entire Geant4 class library. +If your project requires custom C++ user actions, arbitrary physics processes, +custom field equations, native digitizers, parallel worlds, advanced biasing, or +CUDA transport, you will need native Geant4 C++ or a lower-level binding. + +That boundary is useful: use this package when a GDML detector, configured +physics list, standard generator, native transport, and Python analysis are a +good fit. Move to native C++ when transport-time customization is central to the +experiment. + +## 35. Suggested projects + +After completing the examples, build these projects in order: + +1. A monoenergetic gamma beam through the water box, measuring deposited energy. +2. A silicon target with an electron beam and a magnetic field. +3. An optical scintillator with a refractive-index table and optical-photon + count. +4. A GDML detector imported from a CAD/STL workflow. +5. A multithreaded dose map with Parquet output. +6. An MPI campaign that writes one result file per rank and performs a separate + merge and uncertainty analysis. + +For each project, write down the geometry, material, source, physics list, +field, scoring definition, event count, seed policy, and validation checks. + +# Part IV - A thesis-style learning curriculum + +## 36. How to read this book + +This book is organized as a progression rather than a catalogue of APIs. Read +the first five chapters in order. After that, choose a project and return to the +relevant chapters while implementing it. Every serious simulation should be +understandable in four layers: + +1. **Physical question** - what quantity are you trying to estimate? +2. **Monte Carlo model** - what particles, materials, processes, and geometry + represent that question? +3. **Software implementation** - which GDML, Python configuration, and scoring + code implement the model? +4. **Evidence** - what checks show that the result is correct and precise? + +Do not begin by copying a large detector. Begin with a small experiment for +which you can predict the direction of the answer. A simulation becomes useful +when it is falsifiable: you should know what result would convince you that the +geometry, physics, or source is wrong. + +## 37. Monte Carlo foundations + +Geant4 transports individual particles through matter. A run with `N` primary +events is a random sample from a physical model. If `X_i` is the scored value +from event `i`, the sample mean is: + +```text +mean(X) = (X_1 + X_2 + ... + X_N) / N +``` + +For independent events, the standard error of the mean decreases approximately +as `1 / sqrt(N)`. Doubling the precision therefore requires roughly four times +as many events. This is why a visually impressive one-event display is not a +measurement. + +For a quantity with sample variance `s^2`, estimate the standard error as: + +```text +standard_error = s / sqrt(N) +``` + +In Python: + +```python +import numpy as np + +values = ak.to_numpy(g4.Scoring.energy_deposit(events)) +mean = values.mean() +error = values.std(ddof=1) / np.sqrt(len(values)) +print(f"{mean:.4g} +/- {error:.3g} keV") +``` + +This statistical error does not include systematic uncertainty from geometry, +material data, physics-list choice, calibration, or an incorrect source model. + +## 38. The first laboratory: a beam and a target + +### Objective + +Build a controlled experiment in which a monoenergetic electron crosses a water +target. Record the steps and estimate deposited energy per event. + +### Procedure + +```python +from pathlib import Path + +import awkward as ak +import numpy as np +import geant4_python_application as g4 + + +def run_experiment(n_events=1000): + with g4.Application(gdml=g4.basic_gdml, seed=42) as app: + app.set_event_fields( + { + "run", + "id", + "track_particle", + "track_id", + "step_energy", + "step_volume", + "step_position", + } + ) + app.commands( + [ + "/gun/particle e-", + "/gun/energy 1 MeV", + "/gun/position 0 0 -300 mm", + "/gun/direction 0 0 1", + ] + ) + return app.run(n_events) + + +events = run_experiment() +edep = ak.to_numpy(g4.Scoring.energy_deposit(events, volume="box")) +print("N =", len(edep)) +print("mean [keV] =", np.mean(edep)) +print("standard deviation [keV] =", np.std(edep, ddof=1)) +``` + +### Interpretation + +The result is not just a number. Record the particle, energy, target material, +target dimensions, physics list, seed, event count, and volume name alongside +it. A result without configuration metadata cannot be reproduced. + +### Exercises + +1. Change the electron energy from 1 MeV to 10 MeV. +2. Replace the electron with a gamma ray and explain the changed distribution. +3. Move the source outside the world and observe the diagnostic behavior. +4. Set the target volume as sensitive and compare the resulting step records. + +## 39. Designing a detector model + +### 39.1 Start with the question + +If the question is “how much energy reaches the sensor?”, the geometry needs a +sensor volume and a scoring definition. If the question is “what is the track +length in gas?”, the geometry needs the gas volume and the analysis must sum +step lengths or use an appropriate recorded field. Geometry should be driven by +the observable, not by visual complexity. + +### 39.2 World volume + +The world must contain every possible particle position and secondary track. +Make it large enough that particles do not begin outside it, but do not make it +unnecessarily enormous when using detailed navigation. Use a simple material +such as air or vacuum unless the physical question requires something else. + +### 39.3 Detector volume + +Give each important detector component a unique physical name. Put the material +on the logical volume and the position on the physical placement. This lets you +reuse a logical volume and place it several times while retaining distinct +physical names for analysis. + +### 39.4 Geometry validation checklist + +Before physics validation, check: + +- world dimensions contain the source and expected secondaries; +- all solids have the intended full lengths and half lengths; +- daughter placements are inside their mothers; +- rotations use the intended convention; +- material names resolve; +- repeated placements have unique analysis names; +- overlap checks pass; +- a one-event visualization looks correct. + +## 40. Materials as physical models + +Material density affects stopping power and interaction probability. Elemental +composition affects cross sections and secondary production. Optical properties +affect refraction, reflection, absorption, and photon yield. A material name is +therefore not merely a label. + +For each custom material, document: + +- density and temperature; +- elemental or component composition; +- state and pressure where relevant; +- optical property tables and their energy/wavelength domain; +- the source of the values; +- the version of the data used. + +Do not extrapolate optical tables silently beyond their defined domain. If a +simulation depends on a material parameter, include a validation plot or a small +numerical check in the project repository. + +## 41. Selecting a physics list responsibly + +A reference physics list is a coherent collection of models and processes. It is +usually safer than assembling individual processes without understanding their +interactions. Select a list based on particle types, energy range, target +materials, and required precision. + +Use this decision process: + +1. Identify all primary and likely secondary particle species. +2. Identify the lowest and highest relevant energies. +3. Identify whether electromagnetic, hadronic, decay, optical, or DNA physics is + required. +4. Choose a documented reference list that covers the domain. +5. Run a small sensitivity comparison against another plausible list. +6. Record the choice and rationale. + +Adding a constructor such as `G4EmDNAPhysics_option2` is an expert operation. +Confirm that the geometry, energy range, chemistry assumptions, and output +interpretation are appropriate before using it for a scientific conclusion. + +## 42. Source modelling and phase space + +The primary source is often the largest systematic uncertainty. Specify: + +- particle species and charge; +- energy distribution; +- position distribution; +- direction and angular distribution; +- event time if relevant; +- correlations among primaries; +- normalization and exposure. + +A source file may contain correlated variables. Preserve those correlations when +turning it into an Awkward Array. Do not independently shuffle energy and +direction unless the physical source is truly uncorrelated. + +For multiple primaries per event, preserve the event boundaries. One event with +ten correlated primary vertices is not equivalent to ten independent events. + +## 43. Data model and analysis architecture + +The event output has a nested structure because physics is nested: + +```text +run + event + primary vertices + tracks + steps +``` + +Use the event as the unit of statistical analysis. If you flatten all steps and +compute a mean without event weights, events with many steps receive more +influence than events with few steps. Decide whether you need an event-level, +track-level, or step-level observable before flattening. + +### Event-level example + +```python +edep_per_event = g4.Scoring.energy_deposit(events) +mean_edep = ak.mean(edep_per_event) +``` + +### Step-level example + +```python +hits = events.hits(volume="box") +positive_steps = ak.flatten(hits.energy, axis=None) +``` + +These answer different questions. State the choice in the analysis document. + +## 44. Scoring design patterns + +### 44.1 Recorded-step scoring + +Use recorded steps when you need positions, processes, track IDs, or custom +Python analysis. This is flexible but can consume substantial memory. + +### 44.2 Native mesh scoring + +Use `NativeScoringMesh` when you need a spatial energy-deposition map and do not +need every track and step in Python. This delegates scoring to native Geant4. + +### 44.3 Hybrid scoring + +Use a small diagnostic run with full steps, then a large production run with +minimal fields or a native mesh. This gives both interpretability and scale. + +### 44.4 Efficiency and normalization + +If you simulate only a biased or filtered sample, the result needs the correct +weighting. The present high-level package does not expose every Geant4 biasing +class, so treat event filtering as an analysis selection, not as an unbiased +estimate of the original flux. + +## 45. Validation as a scientific workflow + +Validation has three distinct meanings: + +1. **Software validation**: does the program run and produce structurally valid + output? +2. **Physics verification**: does the implementation reproduce a known result or + limiting case? +3. **Experimental validation**: does the simulation agree with measured data + within the combined uncertainty? + +For every new detector, create a validation table: + +| Test | Expected behavior | Result | Status | +| ------------------- | ------------------------------ | ------ | --------- | +| Geometry load | All named volumes exist | ... | pass/fail | +| Overlap check | No overlaps in target region | ... | pass/fail | +| Empty field | Straight neutral-particle path | ... | pass/fail | +| Energy conservation | Balance within tolerance | ... | pass/fail | +| Physics comparison | Agreement with reference | ... | pass/fail | +| Thread comparison | Aggregate agreement | ... | pass/fail | + +Keep failed tests visible. A simulation project becomes trustworthy when its +failures are discoverable rather than hidden. + +## 46. Uncertainty studies + +Vary one assumption at a time and compare the resulting distributions: + +- physics list; +- material density or composition; +- detector dimensions; +- source energy or angular spread; +- field strength; +- production settings; +- event count; +- thread count and rank count. + +Use independent seeds for independent systematic scenarios. Use a common-random- +number strategy only when you understand the covariance benefit and can explain +it in the analysis. + +## 47. A capstone project: a small dose study + +### Research question + +Estimate the energy deposited in a water target by a monoenergetic gamma beam, +compare two photon energies, and report a statistical uncertainty. + +### Project structure + +```text +dose-study/ + detector.gdml + run.py + analyze.py + results/ + README.md +``` + +### Run script + +```python +from pathlib import Path +import geant4_python_application as g4 + + +def run_energy(energy_mev, n_events, seed): + gdml = Path("detector.gdml").read_text() + with g4.Application(gdml=gdml, physics="FTFP_BERT", seed=seed) as app: + app.set_event_fields({"id", "step_energy", "step_volume"}) + app.commands( + [ + "/gun/particle gamma", + f"/gun/energy {energy_mev} MeV", + "/gun/position 0 0 -300 mm", + "/gun/direction 0 0 1", + ] + ) + events = app.run(n_events) + g4.io.events_to_parquet(events, f"results/events_{energy_mev}MeV.parquet") + return g4.Scoring.energy_deposit(events, volume="target") + + +if __name__ == "__main__": + for energy in (1, 5): + run_energy(energy, n_events=100_000, seed=1000 + energy) +``` + +### Analysis questions + +1. Does the mean deposited energy increase monotonically with beam energy? +2. How does the fraction of zero-deposition events change? +3. Is the difference larger than the combined statistical uncertainty? +4. Does changing from serial to four threads change the aggregate result? +5. What detector or source assumptions dominate the difference? + +The capstone is complete only when the answer includes configuration metadata, +plots or tables, uncertainties, and a statement of limitations. + +## 48. From Python to native Geant4 + +Eventually you may need a feature outside this package's binding boundary. The +conceptual translation remains useful: + +- Python GDML configuration corresponds to native detector construction. +- `app.run` corresponds to `BeamOn`. +- Python scoring corresponds to native run/event/step actions or scorers. +- Offline callbacks correspond to analysis after an event record exists. +- A reference physics-list name corresponds to native physics-list factory use. + +When moving to C++, first reproduce the Python result with the same geometry, +source, physics list, seed policy, and event count. Then introduce the native +customization one feature at a time. This makes it possible to distinguish a +binding difference from a physics-model difference. + +## 49. Final competence checklist + +A reader is ready to use this package independently when they can: + +- explain the run, event, track, and step hierarchy; +- create and validate a GDML detector; +- choose and justify a physics list; +- define a reproducible source; +- configure fields and optical transport where supported; +- choose an event-level or step-level observable; +- produce an uncertainty estimate; +- save and reload results; +- compare serial, multithreaded, and MPI runs; +- diagnose geometry, data, and source failures; +- state which features require native Geant4; +- document a simulation so another person can reproduce it. + +That is the difference between making a Geant4 script run and conducting a +defensible Monte Carlo study. + +# Part V - How to write a Geant4 Python program + +## 50. The anatomy of a simulation program + +A maintainable simulation program usually contains five layers: + +```text +configuration -> detector/source setup -> transport -> scoring -> analysis +``` + +Keep these layers visible in the code. A useful first file is: + +```text +my_simulation/ + run_simulation.py # creates the application and runs events + analyze_results.py # reads output and computes physics quantities + detector.gdml # geometry and material references + results/ # generated output, not source code +``` + +Do not put a 500,000-event run at module import time. Put execution inside +`main()` and protect it with `if __name__ == "__main__":`. This is important +when launching multiple processes with MPI and makes the file importable from +tests or notebooks. + +## 51. Write the smallest runnable program + +Start by writing this exact skeleton: + +```python +import geant4_python_application as g4 + + +def main(): + with g4.Application(gdml=g4.basic_gdml, seed=1234) as app: + events = app.run(10) + print("events:", len(events)) + + +if __name__ == "__main__": + main() +``` + +Line by line: + +- `import ... as g4` loads the public Python interface. +- `main()` gives the program a clear entry point. +- `Application(...)` creates the simulation controller. +- `gdml=...` supplies the detector description. +- `seed=1234` makes the random sequence reproducible for this configuration. +- `with` starts the native application and stops it even if an exception occurs. +- `app.run(10)` transports ten primary events. +- `events` is a nested `awkward.Array`, not a normal list of Python objects. +- The `__main__` guard prevents accidental execution during import. + +Run it from the project directory: + +```bash +python run_simulation.py +``` + +If this fails, do not add physics complexity yet. First fix installation, +dataset, and environment problems with this smallest program. + +## 52. Add one feature at a time + +The safest way to write a simulation is incremental: + +```python +def main(): + with g4.Application(gdml=g4.basic_gdml, seed=1234) as app: + # 1. Configure the source. + app.generator.use_gun() + app.generator.particle("gamma") + app.generator.energy(1, "MeV") + app.generator.position(0, 0, -30, "cm") + app.generator.direction(0, 0, 1) + + # 2. Run a tiny smoke test. + events = app.run(1) + + print(events) +``` + +Run one event, inspect it, and only then increase the event count. This creates +a short feedback loop for source and geometry mistakes. + +## 53. Write a reusable configuration function + +Once the first script works, move configuration into named functions: + +```python +import geant4_python_application as g4 + + +def configure_source(app, particle="gamma", energy_mev=1.0): + app.generator.use_gun() + app.generator.particle(particle) + app.generator.energy(energy_mev, "MeV") + app.generator.position(0, 0, -30, "cm") + app.generator.direction(0, 0, 1) + + +def configure_detector(app): + app.detector.sensitive_volumes = {"box"} + app.detector.check_overlaps() + + +def run_simulation(n_events, seed=1234): + with g4.Application( + gdml=g4.basic_gdml, + physics="FTFP_BERT", + seed=seed, + ) as app: + configure_source(app) + configure_detector(app) + return app.run(n_events) + + +def main(): + events = run_simulation(100) + print("generated", len(events), "events") + + +if __name__ == "__main__": + main() +``` + +The important design choice is that `configure_source` receives an existing +application. It does not create a second application. Geant4 permits only one +kernel/application instance in the worker process. + +## 54. Write a real command-line program + +For research work, hard-coded values should become command-line options: + +```python +import argparse +from pathlib import Path + +import geant4_python_application as g4 + + +def parse_args(): + parser = argparse.ArgumentParser(description="Run a gamma transport study") + parser.add_argument("--events", type=int, default=1000) + parser.add_argument("--energy", type=float, default=1.0) + parser.add_argument("--seed", type=int, default=1234) + parser.add_argument("--threads", type=int, default=0) + parser.add_argument("--gdml", type=Path) + parser.add_argument("--output", type=Path, default=Path("events.parquet")) + return parser.parse_args() + + +def main(): + args = parse_args() + gdml = args.gdml.read_text() if args.gdml else g4.basic_gdml + + with g4.Application( + gdml=gdml, + physics="FTFP_BERT", + n_threads=args.threads, + seed=args.seed, + ) as app: + app.generator.use_gun().particle("gamma").energy(args.energy, "MeV") + app.generator.position(0, 0, -30, "cm").direction(0, 0, 1) + events = app.run(args.events) + + args.output.parent.mkdir(parents=True, exist_ok=True) + g4.io.events_to_parquet(events, args.output) + print(f"wrote {len(events)} events to {args.output}") + + +if __name__ == "__main__": + main() +``` + +Examples: + +```bash +python run_simulation.py --events 100 --energy 1 +python run_simulation.py --events 100000 --energy 5 --threads 8 +python run_simulation.py --gdml detector.gdml --output results/run.parquet +``` + +Command-line arguments turn a script into a reproducible experiment: the command +itself records the most important choices. + +## 55. Write the analysis program separately + +Do not rerun Geant4 just to change a plot or a summary statistic. Save the event +data and write a separate analysis program: + +```python +from pathlib import Path + +import awkward as ak +import numpy as np +import geant4_python_application as g4 + + +def main(): + events = g4.io.events_from_parquet(Path("results/events.parquet")) + edep = ak.to_numpy(g4.Scoring.energy_deposit(events, volume="box")) + mean = float(np.mean(edep)) + error = float(np.std(edep, ddof=1) / np.sqrt(len(edep))) + print(f"N={len(edep)}") + print(f"mean deposited energy = {mean:.5g} +/- {error:.3g} keV") + + +if __name__ == "__main__": + main() +``` + +This separation lets you improve the analysis without changing the simulated +sample. It also makes code review easier: transport code answers “what was +simulated?” and analysis code answers “how was it summarized?” + +## 56. Write a detector as a parameterized project + +For a detector that changes between studies, keep the GDML template and the +Python run code separate. Generate a GDML string from a small configuration +object, or keep several checked-in GDML files with explicit names: + +```python +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class DetectorConfig: + path: Path + target_name: str + + +def load_detector(config: DetectorConfig) -> str: + text = config.path.read_text() + if not text.strip(): + raise ValueError(f"empty GDML file: {config.path}") + return text +``` + +Validate the configuration before starting a long run. Fail early when a file is +missing, a volume name is wrong, or the event count is negative. + +## 57. Write tests before scaling up + +Add tests that exercise the code without requiring a large physics sample: + +```python +def test_source_and_event_count(): + with g4.Application(gdml=g4.basic_gdml, seed=9) as app: + app.generator.particle("gamma").energy(1, "MeV") + events = app.run(3) + + assert len(events) == 3 + assert "id" in events.fields +``` + +Add a geometry test: + +```python +def test_detector_names(): + with g4.Application(gdml=g4.basic_gdml) as app: + assert "box" in app.detector.physical_volumes + app.detector.check_overlaps() +``` + +Tests are not a substitute for physics validation, but they prevent ordinary +programming changes from silently breaking the experiment. + +## 58. A complete writer's template + +Use this template as the starting point for a serious new simulation: + +```python +#!/usr/bin/env python3 +"""Transport primary particles through a GDML detector and save events.""" + +import argparse +from pathlib import Path + +import geant4_python_application as g4 + + +def build_parser(): + parser = argparse.ArgumentParser() + parser.add_argument("--events", type=int, default=1000) + parser.add_argument("--seed", type=int, default=1234) + parser.add_argument("--threads", type=int, default=0) + parser.add_argument("--output", type=Path, default=Path("events.parquet")) + return parser + + +def configure(app): + app.set_event_fields( + { + "run", + "id", + "primaries", + "track_particle", + "track_creator_process", + "step_energy", + "step_volume", + } + ) + app.generator.use_gps() + app.generator.particle("gamma").energy(2, "MeV") + app.generator.position(0, 0, -10, "cm").direction(0, 0, 1) + app.detector.sensitive_volumes = {"box"} + app.detector.check_overlaps() + + +def run(args): + with g4.Application( + gdml=g4.basic_gdml, + physics="FTFP_BERT", + n_threads=args.threads, + seed=args.seed, + ) as app: + configure(app) + return app.run(args.events) + + +def main(): + args = build_parser().parse_args() + if args.events <= 0: + raise SystemExit("--events must be positive") + events = run(args) + args.output.parent.mkdir(parents=True, exist_ok=True) + g4.io.events_to_parquet(events, args.output) + print(f"saved {len(events)} events to {args.output}") + + +if __name__ == "__main__": + main() +``` + +When adapting this template, change one section at a time and keep the +simulation executable after each change. This is how a short educational script +becomes a dependable research program. diff --git a/README.md b/README.md index 5de06079..f5cc690e 100644 --- a/README.md +++ b/README.md @@ -23,136 +23,149 @@ looking at [geant4_pybind](https://github.com/HaarigerHarald/geant4_pybind). - The complete event data is available as an [awkward array](https://github.com/scikit-hep/awkward) -## Installation - -The package is pip installable and should work on all major platforms. Geant4 is -not required to be installed on the system as the PyPI distribution includes a -statically linked version of Geant4. +New to Geant4 or this package? See the +[Python developer guide](PYTHON_DEVELOPER_GUIDE.md). -However, for the time being, it's recommended to install from the GitHub -repository to get the latest version, which makes Geant4 a necessary dependency. +## Platform support -Geant4 is available on conda-forge and can be installed with: +Tested on macOS and Ubuntu; still undergoing broader testing. If you hit a bug, +please report it via +[GitHub Issues](https://github.com/lobis/geant4-python-application/issues) or +reach out to kavyavadhwa@gmail.com. -```bash -conda install -c conda-forge geant4=11.2.2 -``` +## Installation -This project can then be installed via pip: +Prebuilt wheels for macOS (Apple Silicon and Intel) and Linux x86_64, Python 3.8 +– 3.13, bundle Geant4 — no separate Geant4 (C++) installation is needed: ```bash -python -m pip install git+https://github.com/lobis/geant4-python-application +pip install "geant4-python-application[gui]" ``` -You may need additional dependencies to perform the build. I found that these -are usually enuough: +The `gui` extra adds the interactive Qt viewer; leave it out for a physics-only +install. + +Geant4's physics data files download automatically on first use, so you can run +an example straight away: ```bash -conda install cmake ninja gxx_linux-64 +python examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B1/b1.py --batch -n 10 ``` -The provided `Dockerfile` can be used for development purposes and as -documentation on the required dependencies. To build the image run from the root -directory: +`--batch` runs headless; drop it to open the Qt viewer. -```bash -docker build -t geant4-python-application . -``` +Building from source (other platforms, development), the Qt viewer, and data +file management are covered in [INSTALLATION.md](INSTALLATION.md). -Geant4 can be installed using conda: +## Usage -```bash -conda install -c conda-forge geant4 -``` +```python +import geant4_python_application as g4 -For development purposes however, it's recommended to install Geant4 from source -with a similar configuration to the one used in the CI: +# Use a temporary directory for the Geant4 data files (remove this line to use the default location) +g4.application_directory(temp=True) -```bash -git clone https://github.com/Geant4/geant4.git ./geant4-source --depth 1 --branch v11.2.2 +with g4.Application(gdml=g4.basic_gdml, seed=137) as app: + events = app.run(100) -cmake -B ./geant4-build -S ./geant4-source -DCMAKE_INSTALL_PREFIX=./geant4-install -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_STANDARD=17 -DGEANT4_USE_GDML=ON -DGEANT4_INSTALL_EXAMPLES=OFF -DGEANT4_INSTALL_DATA=OFF -DGEANT4_BUILD_TLS_MODEL=global-dynamic -DBUILD_STATIC_LIBS=ON -DBUILD_SHARED_LIBS=OFF -DCMAKE_CXX_FLAGS=-fPIC -DCMAKE_C_FLAGS=-fPIC -DGEANT4_USE_SYSTEM_EXPAT=OFF -cmake --build ./geant4-build --parallel $(nproc) --config Release --target install +print(events) ``` -One common installation issue is the dependency `xerces-c`. You may be able to -install it from your package manager (e.g. `apt-get install libxerces-c-dev) but -it's also possible to build it from source. In this case make sure the -installation directory is accessible or just use the default system path. +A Geant4 reference physics list can be selected by name: -```bash -git clone https://github.com/apache/xerces-c.git ./xerces-source -git -C ./xerces-source checkout tags/v3.2.5 -cmake -B ./xerces-build -S ./xerces-source -DCMAKE_INSTALL_PREFIX=./xerces-install -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_STANDARD=17 -DBUILD_SHARED_LIBS=OFF -DCMAKE_CXX_FLAGS=-fPIC -DCMAKE_C_FLAGS=-fPIC -Dnetwork-accessor=socket -Dtranscoder=iconv -cmake --build ./xerces-build --parallel $(nproc) --config Release --target install +```python +with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT") as app: + events = app.run(100) + +print(g4.Application.available_physics_lists()) ``` -After all dependencies are met, you should be able to install the package by -running the following command from the root directory: +Particle guns and Geant4's General Particle Source have a Python interface: -```bash -pip install . +```python +with g4.Application(gdml=g4.basic_gdml) as app: + app.generator.use_gps().particle("gamma").energy(2, "MeV") + app.generator.position(0, 0, -10, "cm").direction(0, 0, 1) + events = app.run(100) ``` -### Geant4 data files +Advanced GPS distributions remain accessible through +`app.generator.commands(["/gps/pos/type Plane", ...])`. -Geant4 comes with a large set of data files which are required in order to run. -The data files are the bulk of the Geant4 installation and can be quite large. -These data files are not included in the Python wheels for this package due to -its size. +Optical physics and uniform global magnetic/electric fields can be enabled +directly: -The python package automatically manages the download of the data files. To -check the location of the data files, run the following command: - -```bash -python -c "import geant4_python_application; print(geant4_python_application.get_data_path())" +```python +with g4.Application(gdml=my_optical_gdml, optical=True) as app: + app.detector.magnetic_field = (0.0, 0.0, 1.5) # tesla + app.detector.electric_field = (0.0, 0.0, 0.5) # kV/cm + events = app.run(100) ``` -**Uninstalling the package will not remove the data files. The user is -responsible for removing them manually.** - -#### Overriding the default data path - -The default data path can be overridden by calling `application_directory`. This -should be done before the application is initialized. +Optical scintillator GDML + tuning helpers: ```python -from geant4_python_application import application_directory +from geant4_python_application import optical as gopt -application_directory("/some/other/path") +with g4.Application(gdml=gopt.optical_water_gdml, optical=True) as app: + app.commands(gopt.scintillation_commands(yield_factor=1.0)) + events = app.run(100) # track.particle contains 'opticalphoton' ``` -Overriding the default data directory is encouraged when submitting batch jobs -to a cluster in order to avoid downloading the data files multiple times. In -this case the application directory should point to some shared location. +Recorded steps can be scored per event or into a Cartesian energy mesh: -#### Using a temporary directory +```python +energy_per_event = g4.Scoring.energy_deposit(events, volume="detector") +mesh, edges = g4.Scoring.energy_mesh(events, bins=(20, 20, 40)) +``` -A temporary directory can be used by calling: +Python run/event/track/step callbacks (offline over awkward arrays): ```python -from geant4_python_application import application_directory - -application_directory(temp=True) +with g4.Application(gdml=g4.basic_gdml) as app: + events = app.run_with_callbacks( + 100, + on_run=lambda evs: print(len(evs)), + on_event=lambda ev: None, + on_track=lambda tr, ev: None, + on_step=lambda st, tr, ev: None, + ) ``` -The operating system should take care of cleaning up the temporary directory. +Extra physics constructors incl. DNA, CAD/VTK/ROOT/HepMC, and MPI splitting: -It is possible that the operating system will delete only some of the files in -the temporary directory which can lead to a Geant4 runtime error. In this case -it's recommended to delete the temporary directory manually and let the -application recreate it again. +```python +print(g4.Application.available_extra_physics()) +with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT") as app: + app.add_physics( + "G4EmDNAPhysics_option2" + ) # or G4EmExtraPhysics, G4OpticalPhysics... + events = app.run(100) + +from geant4_python_application import cad, io as gio, distributed as dist + +gdml = cad.mesh_to_gdml(*cad.cube_mesh(100.0)) # or cad.stl_ascii_to_gdml(stl_text) +gio.mesh_to_vtk(mesh, edges, "mesh.vtk") +gio.events_to_parquet(events, "events.parquet") +gio.events_to_root(events, "events.root") # needs uproot +primaries = gio.hepmc_to_primaries(open("events.hepmc").read()) + +rank, size = dist.get_rank_size() +start, count = dist.split_count(1000, rank, size) # mpiexec -n 2 python run.py +``` -## Usage +### Interactive Qt visualization + +Install with the `gui` extra (`pip install "geant4-python-application[gui]"`), +or for source builds see [INSTALLATION.md](INSTALLATION.md). Then open Geant4's +interactive Qt viewer. The call returns when the window is closed, and commands +can be entered in the viewer's command panel: ```python import geant4_python_application as g4 -# Use a temporary directory for the Geant4 data files (remove this line to use the default location) -g4.application_directory(temp=True) - with g4.Application(gdml=g4.basic_gdml, seed=137) as app: - events = app.run(n_events=100) - -print(events) + app.command("/gun/particle e-") + app.command("/gun/energy 100 MeV") + app.visualize() ``` diff --git a/docs/FEATURE_SUPPORT.md b/docs/FEATURE_SUPPORT.md new file mode 100644 index 00000000..ca830d30 --- /dev/null +++ b/docs/FEATURE_SUPPORT.md @@ -0,0 +1,33 @@ +# Geant4 feature support audit + +This matrix distinguishes native Geant4 execution from Python adapters. A +feature is not marked supported merely because a similarly named proof script +exists. + +| Capability | Status | Current implementation | +| ---------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| Detector construction | Partial | Complete GDML strings/files; no Python subclass of `G4VUserDetectorConstruction` | +| Run/event/track/step actions | Partial | Native built-in C++ actions record events; user callbacks run after transport | +| Multiple primary vertices | Supported | Nested Awkward `primaries` records generate and preserve all vertices in each event | +| Sensitive detectors/hits | Partial | Logical volumes can be marked sensitive and all steps are recorded; no custom Python `ProcessHits` or native typed hit collection | +| Parameterised geometry | Partial | Expanded placements can be generated in Python/GDML; no live `G4VPVParameterisation` callback | +| Physics lists/processes | Partial | Reference lists and a curated set of `G4VPhysicsConstructor` classes; no arbitrary Python process/particle subclass | +| Fields/equations/steppers | Partial | Uniform global B and E with native Geant4 integration; no user equation/stepper binding | +| Optical materials/processes | Supported (GDML) | RINDEX, scintillation, WLS and optical surfaces execute natively; no Python PMT/SD callback | +| Repeated geometry | Adapter | Regular layouts can be expanded to ordinary GDML placements; no live parameterisation callback | +| Native scoring meshes | Supported (energy) | `NativeScoringMesh` drives `G4ScoringManager`; Python `Scoring` remains a separate post-run tool | +| MPI/distributed | Partial | `mpi4py` rank discovery and event splitting; no Geant4 MPI manager or automatic merge | +| DNA | Partial | DNA physics and chemistry constructors are selectable; chemistry scheduler/time-step actions are not exposed | +| ROOT | Adapter | `uproot` summary I/O, not Geant4 ROOT analysis classes | +| VTK | Adapter | Legacy VTK mesh export, not Geant4 VTK visualization classes | +| CAD | Adapter | ASCII STL/tessellated GDML, not STEP/IGES or a CAD kernel | +| Pythia/HepMC | Partial adapter | Minimal HepMC2 primary import; no Pythia native integration | +| DICOM/medical | Partial | GDML medical geometries and dose post-processing; no DICOM image/voxel pipeline | + +## Binding work still required + +Python subclassing of Geant4 virtual interfaces is complicated by the package's +worker-process architecture: arbitrary Python callables must execute safely in +the simulation process and cannot be treated as ordinary pickleable state. New +live callback APIs therefore require an explicit callback transport/lifetime +design, not only pybind11 trampoline declarations. diff --git a/examples/geant4-examples/00_qt_viewer_smoke.py b/examples/geant4-examples/00_qt_viewer_smoke.py new file mode 100644 index 00000000..5150ba0d --- /dev/null +++ b/examples/geant4-examples/00_qt_viewer_smoke.py @@ -0,0 +1,28 @@ +"""00 - Qt interactive viewer (requires display, e.g. XQuartz on macOS). + +Build with Qt first: + CMAKE_PREFIX_PATH=/usr/local:/opt/homebrew \ + python -m pip install -e . --no-build-isolation \ + -Ccmake.define.GEANT4_PYTHON_APPLICATION_VISUALIZATION=ON + +Then run this script. A Qt window opens; enter Geant4 commands in its +command panel. Close the window to return (app.visualize blocks). +Run: python 00_qt_viewer_smoke.py +""" + +from __future__ import annotations + + +def main() -> None: + import geant4_python_application as g4 + + print(f"visualization_available = {g4.Application.visualization_available()}") + assert g4.Application.visualization_available(), "Reinstall with Qt (see docstring)" + with g4.Application(gdml=g4.basic_gdml, seed=137) as app: + app.command("/gun/particle e-") + app.command("/gun/energy 100 MeV") + app.visualize() # default: OGL + drawVolume + trajectories + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/01_reference_physics_lists.py b/examples/geant4-examples/01_reference_physics_lists.py new file mode 100644 index 00000000..d2630ac9 --- /dev/null +++ b/examples/geant4-examples/01_reference_physics_lists.py @@ -0,0 +1,47 @@ +"""01 - Reference physics-list selection. + +Shows listing Geant4 reference lists, running with FTFP_BERT vs custom, +and handling an unknown list name. +Run: python 01_reference_physics_lists.py +""" + +from __future__ import annotations + + +def main() -> None: + import sys + + import geant4_python_application as g4 + + lists = g4.Application.available_physics_lists() + print(f"Available physics lists: {len(lists)}") + print(f"Sample: {lists[:6]}") + assert "custom" in lists + assert "FTFP_BERT" in lists + + # Reference list + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=11) as app: + events = app.run(2) + print(f"FTFP_BERT: ran {len(events)} events") + + # Default custom list (EmOption4 + FTFP_BERT hadronic) + with g4.Application(gdml=g4.basic_gdml, physics="custom", seed=11) as app: + events = app.run(2) + print(f"custom: ran {len(events)} events") + + # Unknown name raises + try: + with g4.Application(gdml=g4.basic_gdml, physics="NOT_A_LIST") as app: + app.run(1) + except Exception as exc: + print(f"Unknown list correctly raised: {type(exc).__name__}: {exc}") + + if "--vis" in sys.argv: + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=11) as app: + app.command("/gun/particle gamma") + app.command("/gun/energy 2 MeV") + app.visualize() # Qt viewer; close window to return + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/02_gps_sources.py b/examples/geant4-examples/02_gps_sources.py new file mode 100644 index 00000000..f1834172 --- /dev/null +++ b/examples/geant4-examples/02_gps_sources.py @@ -0,0 +1,50 @@ +"""02 - GPS and configurable particle sources. + +Shows particle gun vs General Particle Source (GPS), plus advanced GPS +commands and awkward-array primaries. +Run: python 02_gps_sources.py +""" + +from __future__ import annotations + + +def main() -> None: + import sys + + import awkward as ak + import numpy as np + + import geant4_python_application as g4 + + with g4.Application(gdml=g4.basic_gdml, seed=12) as app: + # Particle gun + app.generator.use_gun() + print(f"generator type: {app.generator.type}") + app.command("/gun/particle gamma") + app.command("/gun/energy 2 MeV") + events = app.run(2) + print(f"gun primaries (keV): {ak.flatten(events.primaries.energy)}") + + # GPS with Python helpers + app.generator.use_gps().particle("gamma").energy(2, "MeV") + app.generator.position(0, 0, -10, "cm").direction(0, 0, 1) + print(f"generator type: {app.generator.type}") + events = app.run(2) + energies = ak.to_numpy(ak.flatten(events.primaries.energy)) + print(f"GPS primaries (keV, expect ~2000): {energies}") + assert np.allclose(energies, 2000) + + # Advanced GPS distribution via raw commands + app.generator.commands(["/gps/pos/type Point", "/gps/pos/centre 0 0 0 cm"]) + events = app.run(1) + print(f"advanced GPS: ran {len(events)} event(s)") + + if "--vis" in sys.argv: + with g4.Application(gdml=g4.basic_gdml, seed=12) as app: + app.generator.use_gps().particle("gamma").energy(2, "MeV") + app.generator.position(0, 0, -10, "cm").direction(0, 0, 1) + app.visualize() # Qt viewer; close window to return + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/03_scoring_meshes_hits.py b/examples/geant4-examples/03_scoring_meshes_hits.py new file mode 100644 index 00000000..83c4b45a --- /dev/null +++ b/examples/geant4-examples/03_scoring_meshes_hits.py @@ -0,0 +1,47 @@ +"""03 - Scoring meshes and hit collections. + +Shows offline scoring over recorded steps: per-event energy deposit, +Cartesian energy mesh (numpy histogramdd), and hit filtering. +Run: python 03_scoring_meshes_hits.py +""" + +from __future__ import annotations + + +def main() -> None: + import sys + + import awkward as ak + + import geant4_python_application as g4 + + with g4.Application(gdml=g4.basic_gdml, seed=13) as app: + app.command("/gun/particle e-") + app.command("/gun/energy 10 MeV") + events = app.run(3) + + print(f"event fields: {events.fields}") + + edep = g4.Scoring.energy_deposit(events) + print(f"deposited energy per event (keV): {ak.to_numpy(edep)}") + + mesh, edges = g4.Scoring.energy_mesh(events, bins=(4, 5, 6)) + print(f"mesh shape: {mesh.shape}, total: {mesh.sum():.2f} keV") + + hits = events.hits() + n_hits = len(ak.flatten(hits.energy, axis=None)) + print(f"total positive-energy steps (hits): {n_hits}") + + # Volume-restricted scoring (physical volume name 'box' in basic_gdml) + edep_box = g4.Scoring.energy_deposit(events, volume="box") + print(f"energy in 'box' per event (keV): {ak.to_numpy(edep_box)}") + + if "--vis" in sys.argv: + with g4.Application(gdml=g4.basic_gdml, seed=13) as app: + app.command("/gun/particle e-") + app.command("/gun/energy 10 MeV") + app.visualize() # Qt viewer; close window to return + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/04_magnetic_electric_fields.py b/examples/geant4-examples/04_magnetic_electric_fields.py new file mode 100644 index 00000000..60c45fbd --- /dev/null +++ b/examples/geant4-examples/04_magnetic_electric_fields.py @@ -0,0 +1,43 @@ +"""04 - Uniform magnetic and electric fields. + +Shows setting a uniform global B field (tesla) and E field (kV/cm). +E uses G4EqMagElectricField + ClassicalRK4; B-only uses G4GlobalMagFieldMessenger. +Run: python 04_magnetic_electric_fields.py +""" + +from __future__ import annotations + + +def main() -> None: + import sys + + import geant4_python_application as g4 + + with g4.Application(gdml=g4.basic_gdml, seed=14) as app: + app.detector.magnetic_field = (0.0, 0.0, 1.0) # tesla + app.detector.electric_field = (0.0, 0.0, 0.5) # kV/cm + print(f"B = {app.detector.magnetic_field} T") + print(f"E = {app.detector.electric_field} kV/cm") + app.command("/gun/particle e-") + app.command("/gun/energy 5 MeV") + events = app.run(2) + print(f"ran {len(events)} events with combined B+E field") + + # B-only (original path) + with g4.Application(gdml=g4.basic_gdml, seed=14) as app: + app.detector.magnetic_field = (0.0, 0.0, 1.5) + print(f"B-only = {app.detector.magnetic_field} T") + events = app.run(1) + print(f"ran {len(events)} event with B-only field") + + if "--vis" in sys.argv: + with g4.Application(gdml=g4.basic_gdml, seed=14) as app: + app.detector.magnetic_field = (0.0, 0.0, 1.0) + app.detector.electric_field = (0.0, 0.0, 0.5) + app.command("/gun/particle e-") + app.command("/gun/energy 5 MeV") + app.visualize() # Qt viewer; close window to return + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/05_optical_scintillation.py b/examples/geant4-examples/05_optical_scintillation.py new file mode 100644 index 00000000..0c52f0d0 --- /dev/null +++ b/examples/geant4-examples/05_optical_scintillation.py @@ -0,0 +1,41 @@ +"""05 - Optical materials, surfaces and scintillation. + +Uses GDML-defined RINDEX/ABSLENGTH/SCINTILLATION + skinsurface (see +Geant4 OpNovice) with G4OpticalPhysics. Verifies opticalphoton production. +Run: python 05_optical_scintillation.py +""" + +from __future__ import annotations + + +def main() -> None: + import sys + + import awkward as ak + + import geant4_python_application as g4 + from geant4_python_application import optical as gopt + + print(f"tuning commands: {gopt.scintillation_commands()}") + + with g4.Application(gdml=gopt.optical_water_gdml, optical=True, seed=15) as app: + app.commands(gopt.scintillation_commands(stack_photons=True, max_cerenkov=100)) + app.command("/gun/particle e-") + app.command("/gun/energy 10 MeV") + app.command("/gun/position 0 0 0 mm") + app.command("/gun/direction 1 0 0") + events = app.run(2) + print(f"ran {len(events)} events with optical physics") + parts = sorted(set(ak.to_list(ak.flatten(events.track.particle, axis=None)))) + print(f"track particles: {parts}") + assert "opticalphoton" in parts, "expected scintillation/Cerenkov photons" + + if "--vis" in sys.argv: + with g4.Application(gdml=gopt.optical_water_gdml, optical=True, seed=15) as app: + app.command("/gun/particle e-") + app.command("/gun/energy 10 MeV") + app.visualize() # Qt viewer; close window to return + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/06_callbacks.py b/examples/geant4-examples/06_callbacks.py new file mode 100644 index 00000000..e584a6c7 --- /dev/null +++ b/examples/geant4-examples/06_callbacks.py @@ -0,0 +1,51 @@ +"""06 - Python run/event/track/step callbacks. + +Shows Application.run_with_callbacks() - an offline callback layer over +the awkward event model (on_run / on_event / on_track / on_step). +Run: python 06_callbacks.py +""" + +from __future__ import annotations + + +def main() -> None: + import sys + + import geant4_python_application as g4 + + counts = {"run": 0, "event": 0, "track": 0, "step": 0} + + def on_run(events) -> None: + counts["run"] += 1 + print(f"on_run: {len(events)} events") + + def on_event(event) -> None: + counts["event"] += 1 + + def on_track(track, event) -> None: + counts["track"] += 1 + + def on_step(step, track, event) -> None: + counts["step"] += 1 + + with g4.Application(gdml=g4.basic_gdml, seed=16) as app: + app.command("/gun/particle gamma") + app.command("/gun/energy 1 MeV") + events = app.run_with_callbacks( + 3, on_run=on_run, on_event=on_event, on_track=on_track, on_step=on_step + ) + print(f"ran {len(events)} events") + + print(f"callback counts: {counts}") + assert counts["run"] == 1 and counts["event"] == 3 + assert counts["track"] > 0 and counts["step"] > 0 + + if "--vis" in sys.argv: + with g4.Application(gdml=g4.basic_gdml, seed=16) as app: + app.command("/gun/particle gamma") + app.command("/gun/energy 1 MeV") + app.visualize() # Qt viewer; close window to return + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/07_custom_physics.py b/examples/geant4-examples/07_custom_physics.py new file mode 100644 index 00000000..1a2d62ac --- /dev/null +++ b/examples/geant4-examples/07_custom_physics.py @@ -0,0 +1,41 @@ +"""07 - Custom physics processes. + +Shows registering extra G4VPhysicsConstructors by name on top of a +reference or custom list, before initialize/run. +Run: python 07_custom_physics.py +""" + +from __future__ import annotations + + +def main() -> None: + import sys + + import geant4_python_application as g4 + + print(f"extra physics: {g4.Application.available_extra_physics()}") + + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=17) as app: + app.add_physics("G4EmExtraPhysics") + app.command("/gun/particle gamma") + app.command("/gun/energy 1 MeV") + events = app.run(1) + print(f"ran {len(events)} event with G4EmExtraPhysics added") + + # Unknown constructor raises a clear error + try: + with g4.Application(gdml=g4.basic_gdml, seed=17) as app: + app.add_physics("NOT_A_PHYSICS") + except Exception as exc: + print(f"unknown constructor correctly raised: {exc}") + + if "--vis" in sys.argv: + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=17) as app: + app.add_physics("G4EmExtraPhysics") + app.command("/gun/particle gamma") + app.command("/gun/energy 1 MeV") + app.visualize() # Qt viewer; close window to return + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/08_dna_medical.py b/examples/geant4-examples/08_dna_medical.py new file mode 100644 index 00000000..a7a1dbb8 --- /dev/null +++ b/examples/geant4-examples/08_dna_medical.py @@ -0,0 +1,36 @@ +"""08 - DNA chemistry and medical extensions. + +Shows adding Geant4-DNA physics (e.g. option2) for low-energy electron +transport in water. Chemistry constructors are listed but only the +physical stage is run here. +Run: python 08_dna_medical.py +""" + +from __future__ import annotations + + +def main() -> None: + import sys + + import geant4_python_application as g4 + + dna = [x for x in g4.Application.available_extra_physics() if "DNA" in x] + print(f"DNA constructors: {dna}") + + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=19) as app: + app.add_physics("G4EmDNAPhysics_option2") + app.command("/gun/particle e-") + app.command("/gun/energy 100 keV") + events = app.run(1) + print(f"ran {len(events)} event with G4EmDNAPhysics_option2") + + if "--vis" in sys.argv: + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=19) as app: + app.add_physics("G4EmDNAPhysics_option2") + app.command("/gun/particle e-") + app.command("/gun/energy 100 keV") + app.visualize() # Qt viewer; close window to return + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/09_cad_vtk_root_generators.py b/examples/geant4-examples/09_cad_vtk_root_generators.py new file mode 100644 index 00000000..6708ed4c --- /dev/null +++ b/examples/geant4-examples/09_cad_vtk_root_generators.py @@ -0,0 +1,74 @@ +"""09 - CAD, VTK, ROOT and external generators. + +Shows: CAD mesh -> GDML (tessellated), energy mesh -> VTK, events -> +parquet/ROOT, HepMC-ASCII -> primaries. Writes to /tmp. +Run: python 09_cad_vtk_root_generators.py +""" + +from __future__ import annotations + + +def main() -> None: + import sys + from pathlib import Path + + import geant4_python_application as g4 + from geant4_python_application import cad + from geant4_python_application import io as gio + + # CAD: cube mesh -> GDML -> run 1 event + vertices, faces = cad.cube_mesh(100.0) + gdml = cad.mesh_to_gdml(vertices, faces) + assert "tessellated" in gdml + print(f"CAD cube: {len(vertices)} vertices, {len(faces)} triangles") + with g4.Application(gdml=gdml, seed=20) as app: + print(f"CAD GDML ran {len(app.run(1))} event") + + # ASCII STL -> GDML (parse-only check) + stl = ( + "solid c\nfacet normal 0 0 1\nouter loop\n" + "vertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\n" + "endloop\nendfacet\nendsolid c\n" + ) + assert "tessellated" in cad.stl_ascii_to_gdml(stl) + print("STL parse OK") + + # Physics run for I/O demos + with g4.Application(gdml=g4.basic_gdml, seed=21) as app: + app.command("/gun/particle e-") + app.command("/gun/energy 5 MeV") + events = app.run(2) + + # VTK + mesh, edges = g4.Scoring.energy_mesh(events, bins=(3, 3, 3)) + vtk_path = gio.mesh_to_vtk(mesh, edges, Path("/tmp/g4_example_mesh.vtk")) + print(f"VTK written: {vtk_path}") + + # Parquet round-trip (always available via pyarrow) + pq = gio.events_to_parquet(events, Path("/tmp/g4_example_events.parquet")) + assert len(gio.events_from_parquet(pq)) == len(events) + print(f"parquet round-trip OK: {pq}") + + # ROOT via uproot (pip install uproot if missing) + try: + rp = gio.events_to_root(events, Path("/tmp/g4_example_events.root")) + back = gio.events_from_root(rp) + assert len(back["id"]) == len(events) + print(f"ROOT round-trip OK: {rp}") + except ImportError as exc: + print(f"ROOT skipped: {exc}") + + # External generator: minimal HepMC2-ASCII -> awkward primaries + hepmc = "E 0 1 1\nP 1 22 0 0 1 1 0 1\nP 2 11 0.1 0 0.5 0.6 0.0005 1\n" + primaries = gio.hepmc_to_primaries(hepmc) + print(f"HepMC primaries: {list(primaries.particle)}") + + if "--vis" in sys.argv: + with g4.Application(gdml=gdml, seed=20) as app: + app.command("/gun/particle e-") + app.command("/gun/energy 5 MeV") + app.visualize() # Qt viewer; close window to return + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/10_mpi_distributed.py b/examples/geant4-examples/10_mpi_distributed.py new file mode 100644 index 00000000..fdaa6ad1 --- /dev/null +++ b/examples/geant4-examples/10_mpi_distributed.py @@ -0,0 +1,50 @@ +"""10 - MPI and distributed execution. + +Shows rank/size detection (mpi4py -> env vars -> serial fallback) and +even event splitting. Each rank runs its share; gather with +awkward.concatenate or ROOT/parquet. +Run serial: python 10_mpi_distributed.py +Run MPI: mpiexec -n 2 python 10_mpi_distributed.py +""" + +from __future__ import annotations + + +def main() -> None: + import sys + + import geant4_python_application as g4 + from geant4_python_application import distributed as dist + + rank, size = dist.get_rank_size() + print(f"rank {rank}/{size}") + + n_total = 4 + start, count = dist.split_count(n_total, rank, size) + print(f"this rank handles events [{start}:{start + count}] of {n_total}") + + # Each rank runs only its share (here 2 events max per rank for speed) + n_local = min(count, 2) if count else 0 + if n_local: + with g4.Application(gdml=g4.basic_gdml, seed=100 + rank) as app: + app.command("/gun/particle gamma") + app.command("/gun/energy 1 MeV") + events = app.run(n_local) + print(f"rank {rank} ran {len(events)} event(s), ids: {list(events.id)}") + else: + print(f"rank {rank} has no work") + + # Pure splitting logic checks (run on every rank) + assert dist.split_count(10, 0, 2) == (0, 5) + assert dist.split_count(10, 1, 2) == (5, 5) + print("split logic OK") + + if "--vis" in sys.argv and rank == 0: + with g4.Application(gdml=g4.basic_gdml, seed=100) as app: + app.command("/gun/particle gamma") + app.command("/gun/energy 1 MeV") + app.visualize() # Qt viewer (rank 0 only); close window to return + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/11_native_scoring_mesh.py b/examples/geant4-examples/11_native_scoring_mesh.py new file mode 100644 index 00000000..476d84dd --- /dev/null +++ b/examples/geant4-examples/11_native_scoring_mesh.py @@ -0,0 +1,23 @@ +"""11 - Native Geant4 scoring mesh through the Python command interface.""" + +from __future__ import annotations + +from pathlib import Path + +import geant4_python_application as g4 + + +def main() -> None: + output = Path("native_energy_mesh.csv") + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=22) as app: + mesh = g4.NativeScoringMesh(app, "waterMesh", "energyDeposit") + mesh.box((250, 250, 250), (10, 10, 10), unit="mm") + app.commands(["/gun/particle e-", "/gun/energy 10 MeV"]) + app.run(10) + mesh.dump(output) + assert output.exists() and output.stat().st_size > 0 + print(f"native Geant4 scoring mesh written to {output.resolve()}") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/AUDIT.md b/examples/geant4-examples/Geant4-11.4.2-examples-python/AUDIT.md new file mode 100644 index 00000000..89f59ff1 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/AUDIT.md @@ -0,0 +1,40 @@ +# Geant4 example compatibility audit + +- Official CMake targets: 262 +- Official GDML files: 144 +- Standalone GDML candidates: 143 +- Python ports passing: 27 +- Python ports failing/timing out: 0 + +| Python port | Status | Seconds | +| ------------------------------------------------------------ | ------: | ------: | +| `advanced/GoldFoilExperiment/gold_foil.py` | passed | 3.794 | +| `advanced/NanoparticleTherapy/brain_tumor.py` | passed | 4.751 | +| `advanced/NanoparticleTherapy/nano_therapy.py` | passed | 4.411 | +| `basic/B1/b1.py` | passed | 1.739 | +| `basic/B2/b2.py` | passed | 1.933 | +| `basic/B3/b3.py` | passed | 1.779 | +| `basic/B4/b4.py` | passed | 2.447 | +| `basic/B5/b5.py` | passed | 2.266 | +| `extended/analysis/AnaEx01/ana_ex01.py` | passed | 2.004 | +| `extended/analysis/AnaEx02/ana_ex02.py` | passed | 1.198 | +| `extended/analysis/AnaEx03/ana_ex03.py` | passed | 1.104 | +| `extended/electromagnetic/TestEm1/test_em1.py` | passed | 1.353 | +| `extended/eventgenerator/HepMC/hepmc.py` | passed | 1.063 | +| `extended/eventgenerator/exgps/exgps.py` | passed | 1.899 | +| `extended/eventgenerator/particleGun/particle_gun.py` | passed | 1.755 | +| `extended/field/field01/field01.py` | passed | 1.364 | +| `extended/field/field02/field02.py` | passed | 0.966 | +| `extended/hadronic/Hadr00/hadr00.py` | passed | 1.949 | +| `extended/medical/GammaTherapy/gamma_therapy.py` | passed | 1.333 | +| `extended/medical/electronScattering/electron_scattering.py` | passed | 1.794 | +| `extended/optical/LXe/lxe.py` | passed | 1.556 | +| `extended/optical/OpNovice/op_novice.py` | passed | 1.399 | +| `extended/optical/wls/wls.py` | passed | 1.367 | +| `extended/parameterisations/Par01/par01.py` | passed | 1.592 | +| `extended/physicslists/factory/factory.py` | passed | 1.134 | +| `extended/radioactivedecay/rdecay01/rdecay01.py` | passed | 1.167 | +| `extended/runAndEvent/RE01/re01.py` | passed | 1.376 | +| `run_gdml.py` | utility | | + +Only entries marked `passed` are validated as runnable Python ports. diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/COMPATIBILITY.md b/examples/geant4-examples/Geant4-11.4.2-examples-python/COMPATIBILITY.md new file mode 100644 index 00000000..33b520c6 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/COMPATIBILITY.md @@ -0,0 +1,78 @@ +# Compatibility with the official Geant4 examples + +This directory contains Python adaptations, not mechanical translations of the +C++ sources. They exercise the same detector concept using functionality +currently exposed by `geant4_python_application`. + +## Runnable adaptations + +| Official family | Python entry point | Coverage | +| ----------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| basic/B1 | `basic/B1/b1.py` | Geometry, beam, volume energy scoring | +| basic/B2 | `basic/B2/b2.py` | Target, tracker chambers, field, step-derived hits | +| basic/B3 | `basic/B3/b3.py` | PET geometry and crystal/patient energy scoring | +| basic/B4 | `basic/B4/b4.py` | Sampling calorimeter and layer energy scoring | +| basic/B5 | `basic/B5/b5.py` | Spectrometer geometry, field, detector energy scoring | +| GoldFoilExperiment | `advanced/GoldFoilExperiment/gold_foil.py` | Rutherford-scattering workflow and CSV output | +| NanoparticleTherapy | `advanced/NanoparticleTherapy/*.py` | Geometric/physics adaptations and dose summaries | +| extended/eventgenerator/exgps | `extended/eventgenerator/exgps/exgps.py` | Native GPS and primary-spectrum output | +| extended/eventgenerator/particleGun | `extended/eventgenerator/particleGun/particle_gun.py` | Python-sampled source modes and transport | +| extended/eventgenerator/HepMC | `extended/eventgenerator/HepMC/hepmc.py` | Minimal HepMC2 parser adapter; no native HepMC SDK | +| extended/field/field01 | `extended/field/field01/field01.py` | Uniform magnetic-field tracking study | +| extended/field/field02 | `extended/field/field02/field02.py` | Native uniform electric field and absorber scoring | +| extended/optical/OpNovice | `extended/optical/OpNovice/op_novice.py` | Native optical transport and photon counting | +| extended/optical/LXe | `extended/optical/LXe/lxe.py` | LXe scintillation/transport; post-run photon analysis replaces PMT hit callbacks | +| extended/optical/wls | `extended/optical/wls/wls.py` | Native WLS absorption/re-emission in a GDML fiber | +| extended/analysis/AnaEx01 | `extended/analysis/AnaEx01/ana_ex01.py` | Calorimeter energy analysis and CSV ntuple | +| extended/analysis/AnaEx02 | `extended/analysis/AnaEx02/ana_ex02.py` | Nested event persistence through Parquet | +| extended/analysis/AnaEx03 | `extended/analysis/AnaEx03/ana_ex03.py` | ROOT summary adapter through uproot | +| extended/electromagnetic/TestEm1 | `extended/electromagnetic/TestEm1/test_em1.py` | Electromagnetic process and energy-deposit survey | +| extended/hadronic/Hadr00 | `extended/hadronic/Hadr00/hadr00.py` | Hadronic target and secondary-particle survey | +| extended/medical/GammaTherapy | `extended/medical/GammaTherapy/gamma_therapy.py` | Simplified therapy geometry with native energy mesh | +| extended/medical/electronScattering | `extended/medical/electronScattering/electron_scattering.py` | Simplified foil-scattering benchmark | +| extended/parameterisations/Par01 | `extended/parameterisations/Par01/par01.py` | Static parameterisation expanded to GDML placements | +| extended/physicslists/factory | `extended/physicslists/factory/factory.py` | Runtime selection from native Geant4 reference lists | +| extended/radioactivedecay/rdecay01 | `extended/radioactivedecay/rdecay01/rdecay01.py` | Native radioactive decay and product survey | +| extended/runAndEvent/RE01 | `extended/runAndEvent/RE01/re01.py` | Multiple primary vertices preserved per event | + +Each entry point opens Qt by default and accepts `--batch` for non-interactive +execution. + +## Standalone GDML geometries + +The official Desktop tree currently contains 144 `.gdml` files. A GDML file that +includes a complete `` and world volume can be tried directly: + +```bash +python run_gdml.py /absolute/path/to/detector.gdml --batch -n 10 +``` + +Many official GDML files are fragments included by a C++ detector or another +GDML file. Those fragments are not standalone examples and must not be passed to +the runner by themselves. + +## Supported concepts, but not faithful class-for-class ports + +- Reference physics lists and selected additional physics constructors +- Particle gun and General Particle Source commands +- Uniform global magnetic and electric fields +- Optical materials/surfaces expressed in GDML and optical physics +- Step-derived scoring, hits, and post-run Python callbacks +- Tessellated STL geometry, VTK/ROOT/Parquet export, minimal HepMC input +- Rank-aware event splitting + +## Still requiring additional native bindings + +- Arbitrary Python subclasses of Geant4 detector, action, process, field, + sensitive-detector, and hit-collection C++ interfaces +- Parameterised geometry callbacks and custom equation/stepper classes +- Live callbacks during transport (current Python callbacks are post-run) +- Arbitrary `G4MultiFunctionalDetector` primitives beyond the command-based + native energy-deposit mesh now exposed by `NativeScoringMesh` +- Full Geant4 MPI lifecycle and result merging +- DNA chemistry scheduling and chemistry-time actions +- Examples that depend on ROOT classes, VTK classes, CAD kernels, DICOM data, + VecGeom, Pythia, or other external native SDKs + +An example in the final category can only be called compatible after its +required native interface is bound and its expected physics result is tested. diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/README.md b/examples/geant4-examples/Geant4-11.4.2-examples-python/README.md new file mode 100644 index 00000000..57d619da --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/README.md @@ -0,0 +1,72 @@ +# Geant4 Python examples + +Runnable Python ports of the Geant4 11.4.2 examples that fit the current +`geant4_python_application` interface. Each entry point opens the Geant4 Qt +viewer by default; pass `--batch` to run without a window. + +## Included ports + +- `basic/B1/b1.py`: medical phantom with tissue and bone scoring volumes. +- `basic/B2/b2.py`: fixed target, six tracker chambers, hits, and transverse + field. +- `basic/B3/b3.py`: schematic PET ring with crystal and patient energy scoring. +- `basic/B4/b4.py`: lead/liquid-argon sampling calorimeter. +- `basic/B5/b5.py`: double-arm spectrometer adaptation with field and detector + scoring. +- `advanced/GoldFoilExperiment/gold_foil.py`: Rutherford gold-foil scattering. +- `extended/eventgenerator/exgps/exgps.py`: GPS source distributions and primary + analysis. +- `extended/eventgenerator/particleGun/particle_gun.py`: sampled particle-gun + modes. +- `extended/eventgenerator/HepMC/hepmc.py`: minimal HepMC2 ASCII primary + adapter. +- `extended/field/field02/field02.py`: constant electric-field transport. +- `extended/optical/OpNovice/op_novice.py`: optical generation and transport. +- `extended/optical/LXe/lxe.py`: liquid-xenon scintillation and optical + transport. +- `extended/optical/wls/wls.py`: wavelength-shifting fiber transport. +- `extended/analysis/AnaEx01/ana_ex01.py`: sampling-calorimeter analysis output. +- `extended/analysis/AnaEx02/ana_ex02.py`: nested Parquet event output. +- `extended/analysis/AnaEx03/ana_ex03.py`: ROOT summary output via uproot. +- `extended/field/field01/field01.py`: magnetic-field tracking study. +- `extended/radioactivedecay/rdecay01/rdecay01.py`: radioactive-decay track + survey. +- `extended/electromagnetic/TestEm1/test_em1.py`: EM process survey. +- `extended/medical/GammaTherapy/gamma_therapy.py`: photon-therapy phantom and + native dose mesh. +- `extended/medical/electronScattering/electron_scattering.py`: foil-scattering + benchmark adaptation. +- `extended/hadronic/Hadr00/hadr00.py`: hadronic target and secondary survey. +- `extended/parameterisations/Par01/par01.py`: expanded parameterised detector + layout. +- `extended/physicslists/factory/factory.py`: native reference-physics-list + factory selection. +- `extended/runAndEvent/RE01/re01.py`: multiple primary vertices in each event. +- `run_gdml.py`: a generic Qt runner for standalone GDML detector geometries. + +## Running an example + +```bash +conda activate geant4-python +cd ~/Desktop/Geant4-11.4.2-examples-python/basic/B1 +python b1.py +``` + +Use the Qt command panel to execute `/run/beamOn 100`. For a non-interactive +run, add `--batch -n 1000`. + +Validate every claimed Python port against an original Geant4 tree with: + +```bash +python tools/audit_examples.py ~/Desktop/Geant4-11.4.2-examples \ + examples/geant4-examples/Geant4-11.4.2-examples-python +``` + +## Not yet portable + +The original Geant4 example tree is retained separately in +`~/Desktop/Geant4-11.4.2-examples`. Examples requiring live custom C++ user +actions, arbitrary fields/processes, native MPI, DNA chemistry scheduling, or +external native SDKs still need additional bindings before they can become +faithful Python ports. The compatibility report distinguishes faithful native +features from Python/GDML adaptations. diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/advanced/GoldFoilExperiment/gold_foil.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/advanced/GoldFoilExperiment/gold_foil.py new file mode 100644 index 00000000..10229a2f --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/advanced/GoldFoilExperiment/gold_foil.py @@ -0,0 +1,145 @@ +"""Rutherford gold foil experiment, ported from the C++ example to geant4-python-application. + +5.5 MeV alphas fired at a 500 nm gold foil in vacuum; records the scattering +angle and exit energy of each primary alpha as it leaves the foil. +""" + +from __future__ import annotations + +import argparse +import csv + +import awkward as ak +import numpy as np + +import geant4_python_application as g4 +from geant4_python_application.application import Message + +# World and foil match DetectorConstruction.cc. G4Box takes half-lengths and +# GDML takes full lengths, so every value here is twice the C++ one. +GOLD_FOIL_GDML = R""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + +FOIL = "GoldFoilPhysical" + + +def scattering(events: ak.Array) -> tuple[np.ndarray, np.ndarray]: + """Angle (deg) and exit energy (MeV) of each primary alpha leaving the foil. + + Mirrors SteppingAction.cc: primary track only, on the step whose pre-volume + is the foil and whose post-volume is not. + """ + tracks = events.track + steps = tracks.step[(tracks.id == 1) & (tracks.particle == "alpha")] + exiting = (steps.volume == FOIL) & (steps.volume_post != FOIL) + + # momentum is a unit vector, so the polar angle from the +z beam axis is acos(z) + momentum_z = ak.flatten(steps.momentum[exiting].pz, axis=None) + energy_kev = ak.flatten(steps.track_kinetic_energy[exiting], axis=None) + + angle = np.degrees(np.arccos(np.clip(ak.to_numpy(momentum_z), -1.0, 1.0))) + return angle, ak.to_numpy(energy_kev) / 1000.0 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=1000) + parser.add_argument("-o", "--output", default="gold_foil_python_results.csv") + parser.add_argument("--seed", type=int, default=137) + parser.add_argument( + "--batch", + action="store_true", + help="run the requested events and write a CSV instead of opening the Qt viewer", + ) + args = parser.parse_args() + + with g4.Application(gdml=GOLD_FOIL_GDML, seed=args.seed) as app: + app.set_event_fields( + { + "id", + "track_id", + "track_particle", + "step_volume", + "step_volume_post", + "step_momentum", + "step_track_kinetic_energy", + } + ) + # /gun/ commands: energy in MeV, position in cm (PrimaryGeneratorAction.cpp) + app._send_and_recv(Message("generator", "set_particle", ("alpha",), {})) + app._send_and_recv(Message("generator", "set_energy", (5.5,), {})) + app._send_and_recv( + Message("generator", "set_position", ([0.0, 0.0, -5.0],), {}) + ) + app._send_and_recv( + Message("generator", "set_direction", ([0.0, 0.0, 1.0],), {}) + ) + + if not args.batch: + if not app.visualization_available(): + raise RuntimeError( + "This installation was built without Qt visualization support. " + "Reinstall geant4-python-application with " + "GEANT4_PYTHON_APPLICATION_VISUALIZATION=ON." + ) + print("Opening the Qt viewer. In its command panel, run: /run/beamOn 100") + app.visualize( + [ + "/vis/open OGL", + "/vis/drawVolume", + "/vis/viewer/set/style wireframe", + "/vis/scene/add/trajectories smooth", + "/tracking/storeTrajectory 1", + "/vis/scene/endOfEventAction accumulate 100", + ] + ) + return + + events = app.run(args.events) + + angle, energy = scattering(events) + + with open(args.output, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["event_id", "scattering_angle_deg", "exit_energy_MeV"]) + writer.writerows(zip(range(len(angle)), angle, energy)) + + print(f"events simulated : {len(events)}") + print(f"alphas exiting : {len(angle)}") + print(f"angle mean/max : {angle.mean():.4f} / {angle.max():.4f} deg") + print(f"energy mean : {energy.mean():.4f} MeV (from 5.5 MeV)") + print(f"scattered >1 deg : {(angle > 1).sum()} >5 deg: {(angle > 5).sum()}") + print(f"wrote {args.output}") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/advanced/NanoparticleTherapy/brain_tumor.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/advanced/NanoparticleTherapy/brain_tumor.py new file mode 100644 index 00000000..7fb61c55 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/advanced/NanoparticleTherapy/brain_tumor.py @@ -0,0 +1,166 @@ +"""Brain tumour reduction via targeted Au-nanoparticle therapy (assignment). + +Geant4 cannot shrink a tumour - it scores DOSE. Reduction is inferred: + dose -> cell survival (Linear-Quadratic) -> volume reduction estimate. + +Geometry (nested spheres, no overlaps): + World (G4_AIR) -> Skull (G4_BONE_COMPACT_ICRU, r=85mm) + -> Brain (G4_BRAIN_ICRP, r=78mm) + -> Tumor (G4_A-150_TISSUE, r=12mm at x=20mm,z=10mm) + -> NanoCluster (G4_Au or tissue, r=4mm, Au = targeted nanoparticles) + +Beam: 100 keV gammas aimed at tumour. Runs with/without Au (--batch), +reports DER, scaled clinical dose (2 Gy fraction), survival S=exp(-aD-bD^2), +and inferred reduction = 1-S. +""" + +from __future__ import annotations + +import argparse +import math + +import awkward as ak + +import geant4_python_application as g4 + +DENSITY = { + "Skull": 1.85, + "Brain": 1.03, + "Tumor": 1.127, + "ClusterAu": 19.32, + "ClusterTissue": 1.127, +} +KEV_TO_J = 1.602176634e-16 +TX, TZ = 20.0, 10.0 # tumour centre (mm) +ALPHA, BETA = 0.3, 0.03 # /Gy, glioma-like LQ params + + +def geometry(with_gold: bool = True) -> str: + cluster_mat = "G4_Au" if with_gold else "G4_A-150_TISSUE" + return f''' + + + + + + + + + + + + + + + + + + + + + +''' + + +GUN = [ + "/gun/particle gamma", + "/gun/energy 100 keV", + f"/gun/position {TX / 10} 0 -12 cm", + "/gun/direction 0 0 1", +] + + +def run_case(n: int, with_gold: bool) -> dict[str, float]: + with g4.Application(gdml=geometry(with_gold), seed=137) as app: + app.set_event_fields({"id", "step_energy", "step_volume"}) + app.commands(GUN) + events = app.run(n) + e, v = events.track.step.energy, events.track.step.volume + out = { + r: float(ak.sum(e[v == r])) for r in ("Skull", "Brain", "Tumor", "NanoCluster") + } + out["TumorTotal"] = out["Tumor"] + out["NanoCluster"] + return out + + +def dose_gy(kev: float, mass_g: float) -> float: + return kev * KEV_TO_J / (mass_g * 1e-3) + + +def survival(dose: float) -> float: + return math.exp(-ALPHA * dose - BETA * dose * dose) + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=1000) + p.add_argument("--batch", action="store_true") + args = p.parse_args() + + if not args.batch: + with g4.Application(gdml=geometry(True), seed=137) as app: + app.commands(GUN) + app.visualize( + [ + "/vis/open OGL", + "/vis/drawVolume", + "/vis/viewer/set/background black", + "/vis/viewer/set/style surface", + "/vis/geometry/set/colour World 1 1 1 0", + "/vis/geometry/set/colour SkullLV 0.9 0.8 0.5 0.12", + "/vis/geometry/set/colour BrainLV 1 0.5 0.7 0.18", + "/vis/geometry/set/colour TumorLV 0 1 0 0.5", + "/vis/geometry/set/colour NanoClusterLV 1 0 0 1", + "/vis/modeling/trajectories/create/drawByParticleID", + "/vis/modeling/trajectories/drawByParticleID-0/set gamma yellow", + "/vis/modeling/trajectories/drawByParticleID-0/set e- red", + "/vis/modeling/trajectories/drawByParticleID-0/set e+ blue", + "/vis/scene/add/trajectories smooth", + "/tracking/storeTrajectory 1", + "/vis/scene/endOfEventAction accumulate 100", + ] + ) + return + + v_cl = 4 / 3 * math.pi * 0.4**3 + v_tu = 4 / 3 * math.pi * 1.2**3 - v_cl + v_br = 4 / 3 * math.pi * 7.8**3 - 4 / 3 * math.pi * 1.2**3 + v_sk = 4 / 3 * math.pi * 8.5**3 - 4 / 3 * math.pi * 7.8**3 + m = { + "Brain": v_br * DENSITY["Brain"], + "Tumor": v_tu * DENSITY["Tumor"], + "Skull": v_sk * DENSITY["Skull"], + } + + no, yes = run_case(args.events, False), run_case(args.events, True) + der = yes["TumorTotal"] / no["TumorTotal"] if no["TumorTotal"] else float("nan") + + # Scale: give no-Au tumour a clinical 2 Gy fraction; same fluence with Au gives 2*DER. + s_no, s_au = survival(2.0), survival(2.0 * der) + print(f"events: {args.events}, beam 100 keV -> tumour at ({TX},0,{TZ}) mm") + print( + f"[no-Au ] TumorTotal {no['TumorTotal']:.1f} keV | Brain {no['Brain']:.1f} keV | Skull {no['Skull']:.1f} keV" + ) + print( + f"[with-Au] TumorTotal {yes['TumorTotal']:.1f} keV | Brain {yes['Brain']:.1f} keV | Skull {yes['Skull']:.1f} keV" + ) + print(f"DER (tumour Au/no-Au): {der:.2f}") + print( + f"At 2 Gy to tumour without Au: survival {s_no:.3f} (reduction {(1 - s_no) * 100:.1f}%)" + ) + print( + f"Same fluence with Au -> {2 * der:.1f} Gy: survival {s_au:.3e} (reduction {(1 - s_au) * 100:.2f}%)" + ) + print( + "Report: Geant4 proves DOSE boost (photoelectric in Au Z=79); " + "reduction % comes from LQ model, not from cells dying in code. " + "Solid AuSphere exaggerates (real DER 1.1-2); skull/brain doses show sparing." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/advanced/NanoparticleTherapy/nano_therapy.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/advanced/NanoparticleTherapy/nano_therapy.py new file mode 100644 index 00000000..65e3243a --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/advanced/NanoparticleTherapy/nano_therapy.py @@ -0,0 +1,168 @@ +"""Targeted cancer therapy with nanoparticles (assignment). + +Photon beam irradiates a water phantom containing a tissue tumour. +The tumour contains a gold cluster representing targeted gold +nanoparticles (Au, Z=79). kV photons undergo enhanced photoelectric +absorption in gold -> photo/Auger electrons -> local dose enhancement. + +Runs twice (--batch): with Au and without Au (cluster = tissue), +then reports deposited energy, estimated dose, and Dose Enhancement +Ratio: DER = Dose_tumour(Au) / Dose_tumour(no Au). + +Geometry (nested, no overlaps): + World (G4_AIR, box) -> Phantom (G4_WATER, box 90 mm) + -> Tumour (G4_A-150_TISSUE, sphere r=15 mm) + -> NanoCluster (G4_Au or tissue, sphere r=5 mm) +""" + +from __future__ import annotations + +import argparse +import math + +import awkward as ak + +import geant4_python_application as g4 + +# Densities (g/cm3) for dose = energy / mass estimates. +DENSITY = { + "Phantom": 1.0, + "Tumor": 1.127, + "NanoCluster_Au": 19.32, + "NanoCluster_tissue": 1.127, +} +KEV_TO_JOULE = 1.602176634e-16 + + +def geometry(with_gold: bool = True) -> str: + cluster_mat = "G4_Au" if with_gold else "G4_A-150_TISSUE" + return f''' + + + + + + + + + + + + + + + + +''' + + +def run_case(events_n: int, with_gold: bool) -> dict[str, float]: + """Run one configuration, return deposited energy (keV) per region.""" + with g4.Application(gdml=geometry(with_gold), seed=137) as app: + app.set_event_fields({"id", "step_energy", "step_volume"}) + app.commands( + [ + "/gun/particle gamma", + "/gun/energy 100 keV", + "/gun/position 0 0 -10 cm", + "/gun/direction 0 0 1", + ] + ) + events = app.run(events_n) + + energy = events.track.step.energy + volume = events.track.step.volume + out = {} + for region in ("Phantom", "Tumor", "NanoCluster"): + out[region] = float(ak.sum(energy[volume == region])) + out["TumorTotal"] = out["Tumor"] + out["NanoCluster"] + return out + + +def dose_gy(energy_kev: float, mass_g: float) -> float: + return energy_kev * KEV_TO_JOULE / (mass_g * 1e-3) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=1000) + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + + if not args.batch: + with g4.Application(gdml=geometry(with_gold=True), seed=137) as app: + app.commands( + [ + "/gun/particle gamma", + "/gun/energy 100 keV", + "/gun/position 0 0 -10 cm", + "/gun/direction 0 0 1", + ] + ) + app.visualize( + [ + "/vis/open OGL", + "/vis/drawVolume", + "/vis/viewer/set/background black", + "/vis/viewer/set/style surface", + # colour-code by logical volume: phantom=blue, tumour=green, Au=red + "/vis/geometry/set/colour World 1 1 1 0", + "/vis/geometry/set/colour PhantomLV 0 0.3 1 0.15", + "/vis/geometry/set/colour TumorLV 0 1 0 0.35", + "/vis/geometry/set/colour NanoClusterLV 1 0 0 1", + # colour-code tracks: gamma=yellow, e-=red, rest=white + "/vis/modeling/trajectories/create/drawByParticleID", + "/vis/modeling/trajectories/drawByParticleID-0/set gamma yellow", + "/vis/modeling/trajectories/drawByParticleID-0/set e- red", + "/vis/modeling/trajectories/drawByParticleID-0/set e+ blue", + "/vis/scene/add/trajectories smooth", + "/tracking/storeTrajectory 1", + "/vis/scene/endOfEventAction accumulate 100", + ] + ) + return + + # Volumes/masses: cluster r=5mm, tumour r=15mm, phantom 90mm box. + v_cluster = 4 / 3 * math.pi * 0.5**3 # cm3 + v_tumor = 4 / 3 * math.pi * 1.5**3 - v_cluster + v_phantom = 9.0**3 - 4 / 3 * math.pi * 1.5**3 + masses = { + "Phantom": v_phantom * DENSITY["Phantom"], + "Tumor": v_tumor * DENSITY["Tumor"], + } + + no_au = run_case(args.events, with_gold=False) + with_au = run_case(args.events, with_gold=True) + masses_au = dict(masses, NanoCluster=v_cluster * DENSITY["NanoCluster_Au"]) + masses_no = dict(masses, NanoCluster=v_cluster * DENSITY["NanoCluster_tissue"]) + + print(f"events per case: {args.events}, beam: 100 keV gamma") + for label, res, m in ( + ("no-Au ", no_au, masses_no), + ("with-Au", with_au, masses_au), + ): + print( + f"[{label}] Phantom(healthy): {res['Phantom']:.1f} keV " + f"({dose_gy(res['Phantom'], m['Phantom']):.3e} Gy) | " + f"Tumor rim: {res['Tumor']:.1f} keV | " + f"Cluster: {res['NanoCluster']:.1f} keV | " + f"TumorTotal: {res['TumorTotal']:.1f} keV" + ) + + der = ( + with_au["TumorTotal"] / no_au["TumorTotal"] + if no_au["TumorTotal"] + else float("nan") + ) + print(f"Dose Enhancement Ratio (TumorTotal Au / no-Au): {der:.3f}") + print( + "Assignment: explain why DER>1 (photoelectric ~ Z^3/E^3 in Au), " + "why 100 keV shows it but MV beam would show less, and what the " + "Phantom dose says about healthy-tissue sparing." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B1/b1.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B1/b1.py new file mode 100644 index 00000000..47270b06 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B1/b1.py @@ -0,0 +1,71 @@ +"""Python port of Geant4 basic/B1: tissue-and-bone phantom.""" + +from __future__ import annotations + +import argparse + +import awkward as ak + +import geant4_python_application as g4 + +GDML = R""" + + + + + + + + + + + + + + + + + + + + +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=100) + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + + with g4.Application(gdml=GDML, seed=137) as app: + app.set_event_fields({"id", "track_id", "step_energy", "step_volume"}) + app.commands( + [ + "/gun/particle e-", + "/gun/energy 10 MeV", + "/gun/position 0 0 -20 cm", + "/gun/direction 0 0 1", + ] + ) + if not args.batch: + app.visualize( + [ + "/vis/open OGL", + "/vis/drawVolume", + "/vis/viewer/set/style wireframe", + "/vis/scene/add/trajectories smooth", + "/tracking/storeTrajectory 1", + "/vis/scene/endOfEventAction accumulate 100", + ] + ) + return + events = app.run(args.events) + + for volume in ("Tissue", "Bone"): + deposited = ak.sum(events.track.step.energy[events.track.step.volume == volume]) + print(f"{volume} deposited energy: {float(deposited):.3f} keV") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B2/b2.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B2/b2.py new file mode 100644 index 00000000..2cb4c9e1 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B2/b2.py @@ -0,0 +1,74 @@ +"""Python adaptation of Geant4 basic/B2: fixed target and tracker chambers.""" + +from __future__ import annotations + +import argparse + +import awkward as ak + +import geant4_python_application as g4 + + +def geometry() -> str: + chambers = [] + for index in range(6): + z = -50 + index * 35 + radius = 20 + index * 5 + chambers.append( + f'' + ) + volumes = "".join( + f'' + f'' + for i in range(6) + ) + placements = "".join( + f'' + f'' + for i in range(6) + ) + return f""" + + + +{"".join(chambers)} + +{volumes} + +{placements}""" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=100) + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + with g4.Application(gdml=geometry(), physics="FTFP_BERT", seed=137) as app: + app.set_event_fields( + {"id", "track_id", "step_energy", "step_volume", "step_position"} + ) + app.detector.magnetic_field = (0.2, 0.0, 0.0) + app.commands( + [ + "/gun/particle proton", + "/gun/energy 3 GeV", + "/gun/position 0 0 -20 cm", + "/gun/direction 0 0 1", + ] + ) + if not args.batch: + app.visualize() + return + events = app.run(args.events) + steps = ak.flatten(events.track.step, axis=-1) + tracker_hits = steps[ + (steps.energy > 0) & ak.str.starts_with(steps.volume, "Chamber") + ] + print( + f"events: {len(events)}, tracker hits: {len(ak.flatten(tracker_hits.energy, axis=None))}" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B3/b3.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B3/b3.py new file mode 100644 index 00000000..d0bacae9 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B3/b3.py @@ -0,0 +1,69 @@ +"""Python adaptation of Geant4 basic/B3: schematic PET scanner.""" + +from __future__ import annotations + +import argparse +import math + +import awkward as ak + +import geant4_python_application as g4 + + +def geometry() -> str: + placements = [] + for ring in range(3): + z = (ring - 1) * 25 + for crystal in range(24): + angle = crystal * 15 + radians = math.radians(angle) + x, y = 100 * math.cos(radians), 100 * math.sin(radians) + placements.append( + f'' + f'' + f'' + ) + return f""" + + + + + + +{"".join(placements)} +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=100) + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + with g4.Application(gdml=geometry(), physics="FTFP_BERT", seed=137) as app: + app.add_physics("G4RadioactiveDecayPhysics") + app.set_event_fields({"id", "track_id", "step_energy", "step_volume"}) + app.commands( + [ + "/gun/particle gamma", + "/gun/energy 511 keV", + "/gun/position 0 0 0 mm", + "/gun/direction 1 0 0", + ] + ) + if not args.batch: + app.visualize() + return + events = app.run(args.events) + crystal = ak.sum( + events.track.step.energy[ + ak.str.starts_with(events.track.step.volume, "Crystal") + ] + ) + patient = ak.sum(events.track.step.energy[events.track.step.volume == "Patient"]) + print( + f"events: {len(events)}, crystal energy: {float(crystal):.3f} keV, patient energy: {float(patient):.3f} keV" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B4/b4.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B4/b4.py new file mode 100644 index 00000000..aad722d6 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B4/b4.py @@ -0,0 +1,73 @@ +"""Python port of Geant4 basic/B4: lead/liquid-argon sampling calorimeter.""" + +from __future__ import annotations + +import argparse + +import awkward as ak + +import geant4_python_application as g4 + + +def geometry() -> str: + layers = [] + for index in range(10): + z = -67.5 + index * 15 + layers.append( + f'' + f'' + f'' + f'' + ) + return f""" + + + + + + + + {"".join(layers)} +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=100) + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + + with g4.Application(gdml=geometry(), seed=137) as app: + app.set_event_fields({"id", "track_id", "step_energy", "step_volume"}) + app.commands( + [ + "/gun/particle e-", + "/gun/energy 1 GeV", + "/gun/position 0 0 -10 cm", + "/gun/direction 0 0 1", + ] + ) + if not args.batch: + app.visualize( + [ + "/vis/open OGL", + "/vis/drawVolume", + "/vis/viewer/set/style wireframe", + "/vis/scene/add/trajectories smooth", + "/tracking/storeTrajectory 1", + "/vis/scene/endOfEventAction accumulate 100", + ] + ) + return + events = app.run(args.events) + + energy = events.track.step.energy + volume = events.track.step.volume + absorber = ak.sum(energy[ak.str.starts_with(volume, "Absorber")]) + gap = ak.sum(energy[ak.str.starts_with(volume, "Gap")]) + print(f"absorber deposited energy: {float(absorber):.3f} keV") + print(f"gap deposited energy: {float(gap):.3f} keV") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B5/b5.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B5/b5.py new file mode 100644 index 00000000..bc61c1a5 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/basic/B5/b5.py @@ -0,0 +1,78 @@ +"""Python adaptation of Geant4 basic/B5: double-arm spectrometer.""" + +from __future__ import annotations + +import argparse + +import awkward as ak + +import geant4_python_application as g4 + + +def geometry() -> str: + placements = [] + specs = [ + ("Hodoscope1", "HodoLV", -180), + ("Chamber1", "ChamberLV", -130), + ("FieldRegion", "FieldLV", 0), + ("Hodoscope2", "HodoLV", 130), + ("Chamber2", "ChamberLV", 170), + ("ECal", "ECalLV", 220), + ("HCal", "HCalLV", 290), + ] + for name, logical, z in specs: + placements.append( + f'' + ) + return f""" + + + + + + + + + +{"".join(placements)} +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=100) + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + with g4.Application(gdml=geometry(), physics="FTFP_BERT", seed=137) as app: + app.detector.magnetic_field = (0.0, 1.0, 0.0) + app.set_event_fields( + { + "id", + "track_id", + "step_energy", + "step_time", + "step_volume", + "step_position", + } + ) + app.commands( + [ + "/gun/particle proton", + "/gun/energy 1 GeV", + "/gun/position 0 0 -35 cm", + "/gun/direction 0 0 1", + ] + ) + if not args.batch: + app.visualize() + return + events = app.run(args.events) + for volume in ("Hodoscope", "Chamber", "ECal", "HCal"): + mask = ak.str.starts_with(events.track.step.volume, volume) + print( + f"{volume} energy: {float(ak.sum(events.track.step.energy[mask])):.3f} keV" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/analysis/AnaEx01/ana_ex01.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/analysis/AnaEx01/ana_ex01.py new file mode 100644 index 00000000..0017b378 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/analysis/AnaEx01/ana_ex01.py @@ -0,0 +1,72 @@ +"""Python adaptation of extended/analysis/AnaEx01 sampling-calorimeter analysis.""" + +from __future__ import annotations + +import argparse +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import awkward as ak +import numpy as np + +import geant4_python_application as g4 + + +def load_b4_geometry(): + path = Path(__file__).parents[3] / "basic" / "B4" / "b4.py" + spec = spec_from_file_location("g4_b4_geometry", path) + module = module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module.geometry() + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=100) + p.add_argument("--batch", action="store_true") + p.add_argument("-o", "--output", type=Path, default=Path("ana_ex01.csv")) + args = p.parse_args() + with g4.Application(gdml=load_b4_geometry(), physics="FTFP_BERT", seed=137) as app: + app.set_event_fields({"id", "track_id", "step_energy", "step_volume"}) + app.commands( + [ + "/gun/particle e-", + "/gun/energy 1 GeV", + "/gun/position 0 0 -10 cm", + "/gun/direction 0 0 1", + ] + ) + if not args.batch: + app.visualize() + return + events = app.run(args.events) + step = events.track.step + absorber = ak.to_numpy( + ak.sum( + ak.flatten( + step.energy[ak.str.starts_with(step.volume, "Absorber")], axis=-1 + ), + axis=1, + ) + ) + gap = ak.to_numpy( + ak.sum( + ak.flatten(step.energy[ak.str.starts_with(step.volume, "Gap")], axis=-1), + axis=1, + ) + ) + np.savetxt( + args.output, + np.column_stack((np.arange(len(events)), absorber, gap)), + delimiter=",", + header="event,EAbs_keV,EGap_keV", + comments="", + ) + print( + f"events: {len(events)}, wrote {args.output}; mean EAbs={absorber.mean():.3f} keV, EGap={gap.mean():.3f} keV" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/analysis/AnaEx02/ana_ex02.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/analysis/AnaEx02/ana_ex02.py new file mode 100644 index 00000000..8a0a3f33 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/analysis/AnaEx02/ana_ex02.py @@ -0,0 +1,36 @@ +"""Python adaptation of AnaEx02 with nested Parquet event output.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import geant4_python_application as g4 + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=100) + p.add_argument("--batch", action="store_true") + p.add_argument("-o", "--output", type=Path, default=Path("ana_ex02.parquet")) + a = p.parse_args() + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=138) as app: + app.commands( + [ + "/gun/particle e-", + "/gun/energy 100 MeV", + "/gun/position 0 0 -40 cm", + "/gun/direction 0 0 1", + ] + ) + if not a.batch: + app.visualize() + return + events = app.run(a.events) + g4.io.events_to_parquet(events, a.output) + assert len(g4.io.events_from_parquet(a.output)) == len(events) + print(f"events: {len(events)}, wrote {a.output}") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/analysis/AnaEx03/ana_ex03.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/analysis/AnaEx03/ana_ex03.py new file mode 100644 index 00000000..155295af --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/analysis/AnaEx03/ana_ex03.py @@ -0,0 +1,39 @@ +"""Python adaptation of AnaEx03 with ROOT summary output via uproot.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import geant4_python_application as g4 + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=100) + p.add_argument("--batch", action="store_true") + p.add_argument("-o", "--output", type=Path, default=Path("ana_ex03.root")) + a = p.parse_args() + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=139) as app: + app.commands( + [ + "/gun/particle gamma", + "/gun/energy 10 MeV", + "/gun/position 0 0 -40 cm", + "/gun/direction 0 0 1", + ] + ) + if not a.batch: + app.visualize() + return + events = app.run(a.events) + try: + g4.io.events_to_root(events, a.output) + assert len(g4.io.events_from_root(a.output)["id"]) == len(events) + print(f"events: {len(events)}, wrote {a.output}") + except ImportError as exc: + print(f"events: {len(events)}, ROOT output skipped: {exc}") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/electromagnetic/TestEm1/test_em1.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/electromagnetic/TestEm1/test_em1.py new file mode 100644 index 00000000..92bac55b --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/electromagnetic/TestEm1/test_em1.py @@ -0,0 +1,51 @@ +"""Python adaptation of extended/electromagnetic/TestEm1 process survey.""" + +from __future__ import annotations + +import argparse +from collections import Counter + +import awkward as ak + +import geant4_python_application as g4 + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=100) + p.add_argument("--material", default="G4_Al") + p.add_argument("--batch", action="store_true") + a = p.parse_args() + gdml = f'''''' + with g4.Application(gdml=gdml, seed=137) as app: + app.set_event_fields( + { + "id", + "track_id", + "step_energy", + "step_process", + "step_position", + "step_track_kinetic_energy", + } + ) + app.commands( + [ + "/gun/particle e-", + "/gun/energy 10 MeV", + "/gun/position 0 0 -40 cm", + "/gun/direction 0 0 1", + ] + ) + if not a.batch: + app.visualize() + return + events = app.run(a.events) + processes = Counter(ak.to_list(ak.flatten(events.track.step.process, axis=None))) + print( + f"events: {len(events)}, material: {a.material}, deposited: {float(ak.sum(events.track.step.energy)):.3f} keV" + ) + print(f"process calls: {dict(processes)}") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/eventgenerator/HepMC/hepmc.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/eventgenerator/HepMC/hepmc.py new file mode 100644 index 00000000..451129d4 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/eventgenerator/HepMC/hepmc.py @@ -0,0 +1,45 @@ +"""Python adapter for extended/eventgenerator/HepMC. + +Reads a small HepMC2 ASCII subset through the package's Python adapter and +transports final-state particles. It does not embed a native HepMC library. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import geant4_python_application as g4 + +SAMPLE = """E 0 1 1 +P 1 22 0 0 1 1 0 1 +P 2 11 0.1 0 0.5 0.6 0.000511 1 +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input", nargs="?", type=Path) + parser.add_argument( + "-n", + "--events", + type=int, + default=1, + help="accepted for the common audit interface", + ) + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + text = args.input.read_text() if args.input else SAMPLE + primaries = g4.io.hepmc_to_primaries(text) + + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=137) as app: + if not args.batch: + app.visualize() + return + events = app.run(primaries) + print(f"parsed and transported {len(events)} HepMC particles") + assert len(events) == len(primaries) + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/eventgenerator/exgps/exgps.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/eventgenerator/exgps/exgps.py new file mode 100644 index 00000000..e1ae9341 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/eventgenerator/exgps/exgps.py @@ -0,0 +1,47 @@ +"""Python adaptation of extended/eventgenerator/exgps.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import awkward as ak +import numpy as np + +import geant4_python_application as g4 + +GDML = """""" + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=1000) + p.add_argument("--batch", action="store_true") + p.add_argument("-o", "--output", type=Path, default=Path("exgps_primaries.npz")) + args = p.parse_args() + with g4.Application(gdml=GDML, seed=137) as app: + app.generator.use_gps().particle("gamma") + app.generator.commands( + [ + "/gps/pos/type Volume", + "/gps/pos/shape Sphere", + "/gps/pos/radius 4 cm", + "/gps/ang/type iso", + "/gps/ene/type Lin", + "/gps/ene/min 100 keV", + "/gps/ene/max 2 MeV", + ] + ) + if not args.batch: + app.visualize() + return + events = app.run(args.events) + energy = ak.to_numpy(ak.flatten(events.primaries.energy)) + np.savez(args.output, energy_keV=energy) + print( + f"events: {len(events)}, energy range: {energy.min():.2f}-{energy.max():.2f} keV; wrote {args.output}" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/eventgenerator/particleGun/particle_gun.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/eventgenerator/particleGun/particle_gun.py new file mode 100644 index 00000000..4041e19d --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/eventgenerator/particleGun/particle_gun.py @@ -0,0 +1,86 @@ +"""Python adaptation of extended/eventgenerator/particleGun sampling modes.""" + +from __future__ import annotations + +import argparse +import math + +import awkward as ak +import numpy as np + +import geant4_python_application as g4 + + +def _particle(position, direction, energy=1000.0): + return { + "particle": "geantino", + "energy": energy, + "position": dict(zip("xyz", position)), + "direction": dict(zip("xyz", direction)), + } + + +def primaries(count: int, mode: int, seed: int) -> ak.Array: + rng = np.random.default_rng(seed) + records = [] + for _ in range(count): + phi = rng.uniform(0, 2 * math.pi) + if mode == 1: + position = (50 * math.cos(phi), 50 * math.sin(phi), rng.uniform(-50, 50)) + radial = (math.cos(phi), math.sin(phi), 0.0) + records.append( + { + "primaries": [ + _particle(position, radial), + _particle( + tuple(-x for x in position), tuple(-x for x in radial) + ), + _particle((0.0, 0.0, 0.0), (0.0, 0.0, 1.0)), + ] + } + ) + continue + if mode == 2: + energy = float(rng.choice([100, 250, 500, 1000], p=[0.1, 0.3, 0.4, 0.2])) + position = (0.0, 0.0, 0.0) + direction = (0.0, 0.0, 1.0) + elif mode == 4: + radius = np.cbrt(rng.uniform(20**3, 80**3)) + costheta = rng.uniform(-1, 1) + sintheta = math.sqrt(1 - costheta**2) + position = ( + radius * sintheta * math.cos(phi), + radius * sintheta * math.sin(phi), + radius * costheta, + ) + direction = tuple(-value / radius for value in position) + energy = 1000.0 + else: + alpha = rng.uniform(0, math.radians(15 if mode == 3 else 30)) + direction = ( + math.sin(alpha) * math.cos(phi), + math.sin(alpha) * math.sin(phi), + math.cos(alpha), + ) + position, energy = (0.0, 0.0, -100.0), 1000.0 + records.append(_particle(position, direction, energy)) + return ak.Array(records) + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=1000) + p.add_argument("--mode", type=int, choices=(0, 1, 2, 3, 4), default=0) + p.add_argument("--batch", action="store_true") + args = p.parse_args() + source = primaries(args.events, args.mode, 137) + with g4.Application(gdml=g4.basic_gdml, seed=137) as app: + if not args.batch: + app.visualize() + return + events = app.run(source) + print(f"mode {args.mode}: generated and transported {len(events)} primaries") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/field/field01/field01.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/field/field01/field01.py new file mode 100644 index 00000000..6089df76 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/field/field01/field01.py @@ -0,0 +1,46 @@ +"""Python adaptation of extended/field/field01 magnetic tracking study.""" + +from __future__ import annotations + +import argparse + +import awkward as ak + +import geant4_python_application as g4 + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=100) + p.add_argument("--field", type=float, default=1.0) + p.add_argument("--batch", action="store_true") + a = p.parse_args() + with g4.Application(gdml=g4.basic_gdml, seed=137) as app: + app.detector.magnetic_field = (0.0, a.field, 0.0) + app.set_event_fields( + { + "id", + "track_id", + "step_position", + "step_volume", + "step_track_kinetic_energy", + } + ) + app.commands( + [ + "/gun/particle e-", + "/gun/energy 100 MeV", + "/gun/position 0 0 -20 cm", + "/gun/direction 0 0 1", + ] + ) + if not a.batch: + app.visualize() + return + events = app.run(a.events) + nsteps = len(ak.flatten(events.track.step.position.x, axis=None)) + print(f"events: {len(events)}, B=(0,{a.field},0) T, recorded steps: {nsteps}") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/field/field02/field02.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/field/field02/field02.py new file mode 100644 index 00000000..f364cb82 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/field/field02/field02.py @@ -0,0 +1,45 @@ +"""Python adaptation of extended/field/field02 constant-electric-field test.""" + +from __future__ import annotations + +import argparse + +import awkward as ak + +import geant4_python_application as g4 + +GDML = """""" + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=100) + p.add_argument("--field", type=float, default=0.5, help="electric field in kV/cm") + p.add_argument("--batch", action="store_true") + args = p.parse_args() + with g4.Application(gdml=GDML, physics="FTFP_BERT", seed=137) as app: + app.detector.electric_field = (args.field, 0.0, 0.0) + app.set_event_fields( + {"id", "track_id", "step_energy", "step_volume", "step_position"} + ) + app.commands( + [ + "/gun/particle e-", + "/gun/energy 10 MeV", + "/gun/position 0 0 -20 cm", + "/gun/direction 0 0 1", + ] + ) + if not args.batch: + app.visualize() + return + events = app.run(args.events) + steps = events.track.step + edep = float(ak.sum(steps.energy[steps.volume == "Absorber"])) + print( + f"events: {len(events)}, E=({args.field},0,0) kV/cm, absorber energy: {edep:.3f} keV" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/hadronic/Hadr00/hadr00.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/hadronic/Hadr00/hadr00.py new file mode 100644 index 00000000..b3b53800 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/hadronic/Hadr00/hadr00.py @@ -0,0 +1,39 @@ +"""Python adaptation of extended/hadronic/Hadr00 target interactions.""" + +from __future__ import annotations + +import argparse +from collections import Counter + +import awkward as ak + +import geant4_python_application as g4 + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=100) + p.add_argument("--physics", default="FTFP_BERT") + p.add_argument("--batch", action="store_true") + a = p.parse_args() + gdml = """""" + with g4.Application(gdml=gdml, physics=a.physics, seed=137) as app: + app.commands( + [ + "/gun/particle proton", + "/gun/energy 15 GeV", + "/gun/position 0 0 -9 cm", + "/gun/direction 0 0 1", + ] + ) + if not a.batch: + app.visualize() + return + events = app.run(a.events) + secondaries = events.track.particle[events.track.parent_id > 0] + counts = Counter(ak.to_list(ak.flatten(secondaries, axis=None))) + print(f"events: {len(events)}, physics: {a.physics}, secondaries: {dict(counts)}") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/medical/GammaTherapy/gamma_therapy.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/medical/GammaTherapy/gamma_therapy.py new file mode 100644 index 00000000..0ddc9490 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/medical/GammaTherapy/gamma_therapy.py @@ -0,0 +1,47 @@ +"""Python adaptation of extended/medical/GammaTherapy dose scoring.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import awkward as ak + +import geant4_python_application as g4 + +GDML = """""" + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=100) + p.add_argument("--batch", action="store_true") + p.add_argument("-o", "--output", type=Path, default=Path("gamma_therapy_mesh.csv")) + a = p.parse_args() + with g4.Application(gdml=GDML, physics="FTFP_BERT", seed=137) as app: + mesh = g4.NativeScoringMesh(app, "phantomMesh", "doseEnergy").box( + (150, 150, 200), (10, 10, 20), center=(0, 0, 0) + ) + app.commands( + [ + "/gun/particle gamma", + "/gun/energy 6 MeV", + "/gun/position 0 0 -45 cm", + "/gun/direction 0 0 1", + ] + ) + if not a.batch: + app.visualize() + return + events = app.run(a.events) + mesh.dump(a.output) + dose = float( + ak.sum(events.track.step.energy[events.track.step.volume == "Phantom"]) + ) + print( + f"events: {len(events)}, phantom deposited energy: {dose:.3f} keV; mesh: {a.output}" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/medical/electronScattering/electron_scattering.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/medical/electronScattering/electron_scattering.py new file mode 100644 index 00000000..1191dccb --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/medical/electronScattering/electron_scattering.py @@ -0,0 +1,42 @@ +"""Python adaptation of the NRCC electron-scattering benchmark geometry.""" + +from __future__ import annotations + +import argparse + +import awkward as ak + +import geant4_python_application as g4 + +GDML = """""" + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=1000) + p.add_argument("--batch", action="store_true") + a = p.parse_args() + with g4.Application(gdml=GDML, seed=137) as app: + app.set_event_fields( + {"id", "track_id", "step_volume_post", "step_momentum", "step_position"} + ) + app.commands( + [ + "/gun/particle e-", + "/gun/energy 13 MeV", + "/gun/position 0 0 -30 cm", + "/gun/direction 0 0 1", + ] + ) + if not a.batch: + app.visualize() + return + events = app.run(a.events) + step = events.track.step + crossing = step[step.volume_post == "ScoringPlane"] + n = len(ak.flatten(crossing.position.x, axis=None)) + print(f"events: {len(events)}, scoring-plane crossings: {n}") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/optical/LXe/lxe.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/optical/LXe/lxe.py new file mode 100644 index 00000000..71af9c02 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/optical/LXe/lxe.py @@ -0,0 +1,47 @@ +"""Python optical-transport adaptation of extended/optical/LXe. + +This covers liquid-xenon scintillation and optical boundary transport. The +official example's custom PMT hit collection is represented by track analysis +after the run because Python sensitive-detector callbacks are not yet bound. +""" + +from __future__ import annotations + +import argparse + +import awkward as ak + +import geant4_python_application as g4 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=10) + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + + with g4.Application( + gdml=g4.optical.lxe_scintillator_gdml, optical=True, seed=137 + ) as app: + app.commands(g4.optical.scintillation_commands(max_cerenkov=100)) + app.commands( + [ + "/gun/particle e-", + "/gun/energy 1 MeV", + "/gun/position 0 0 0 mm", + "/gun/direction 1 0 0", + ] + ) + if not args.batch: + app.visualize() + return + events = app.run(args.events) + + particles = ak.flatten(events.track.particle, axis=None) + photons = int(ak.sum(particles == "opticalphoton")) + print(f"events: {len(events)}, liquid-xenon optical photons tracked: {photons}") + assert photons > 0 + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/optical/OpNovice/op_novice.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/optical/OpNovice/op_novice.py new file mode 100644 index 00000000..96a3c93a --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/optical/OpNovice/op_novice.py @@ -0,0 +1,40 @@ +"""Python adaptation of extended/optical/OpNovice.""" + +from __future__ import annotations + +import argparse + +import awkward as ak + +import geant4_python_application as g4 + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=10) + p.add_argument("--batch", action="store_true") + args = p.parse_args() + with g4.Application( + gdml=g4.optical.optical_water_gdml, optical=True, seed=137 + ) as app: + app.commands(g4.optical.scintillation_commands(max_cerenkov=100)) + app.commands( + [ + "/gun/particle e-", + "/gun/energy 10 MeV", + "/gun/position 0 0 0 mm", + "/gun/direction 1 0 0", + ] + ) + if not args.batch: + app.visualize() + return + events = app.run(args.events) + particles = ak.flatten(events.track.particle, axis=None) + optical = int(ak.sum(particles == "opticalphoton")) + print(f"events: {len(events)}, optical photons tracked: {optical}") + assert optical > 0 + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/optical/wls/wls.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/optical/wls/wls.py new file mode 100644 index 00000000..46f3f088 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/optical/wls/wls.py @@ -0,0 +1,45 @@ +"""Python optical-transport adaptation of extended/optical/wls. + +The detector is a polystyrene-like WLS core inside lower-index cladding. A +violet optical photon is absorbed and can be re-emitted at lower energy by the +native Geant4 wavelength-shifting process. +""" + +from __future__ import annotations + +import argparse + +import awkward as ak + +import geant4_python_application as g4 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=10) + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + + with g4.Application(gdml=g4.optical.wls_fiber_gdml, optical=True, seed=521) as app: + app.commands( + [ + "/gun/particle opticalphoton", + "/gun/energy 3.5 eV", + "/gun/position 0 0 -190 mm", + "/gun/direction 0 0 1", + "/gun/polarization 1 0 0", + ] + ) + if not args.batch: + app.visualize() + return + events = app.run(args.events) + + particles = ak.flatten(events.track.particle, axis=None) + photons = int(ak.sum(particles == "opticalphoton")) + print(f"events: {len(events)}, WLS optical-photon tracks: {photons}") + assert photons >= args.events + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/parameterisations/Par01/par01.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/parameterisations/Par01/par01.py new file mode 100644 index 00000000..c691fbec --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/parameterisations/Par01/par01.py @@ -0,0 +1,39 @@ +"""Python/GDML adaptation of extended/parameterisations/Par01. + +The repeated detector cells are expanded into fixed GDML placements before +initialization. This covers static parameterised layouts without exposing a +live G4VPVParameterisation callback. +""" + +from __future__ import annotations + +import argparse + +import geant4_python_application as g4 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=10) + parser.add_argument("--cells", type=int, default=12) + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + gdml = g4.geometry.linear_array_gdml(args.cells, spacing_mm=15, box_size_mm=5) + + with g4.Application(gdml=gdml, seed=137) as app: + app.commands( + [ + "/gun/particle geantino", + "/gun/position -120 0 0 mm", + "/gun/direction 1 0 0", + ] + ) + if not args.batch: + app.visualize() + return + events = app.run(args.events) + print(f"events: {len(events)}, expanded detector cells: {args.cells}") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/physicslists/factory/factory.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/physicslists/factory/factory.py new file mode 100644 index 00000000..6fb0124f --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/physicslists/factory/factory.py @@ -0,0 +1,41 @@ +"""Python adaptation of extended/physicslists/factory.""" + +from __future__ import annotations + +import argparse + +import geant4_python_application as g4 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=10) + parser.add_argument("--physics", default="FTFP_BERT") + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + available = g4.Application.available_physics_lists() + if args.physics not in available: + raise SystemExit( + f"unknown physics list {args.physics!r}; choose from: {', '.join(available)}" + ) + + with g4.Application(gdml=g4.basic_gdml, physics=args.physics, seed=137) as app: + app.commands( + [ + "/gun/particle proton", + "/gun/energy 1 GeV", + "/gun/position 0 0 -400 mm", + "/gun/direction 0 0 1", + ] + ) + if not args.batch: + app.visualize() + return + events = app.run(args.events) + print( + f"physics list: {args.physics}; events: {len(events)}; factory lists: {len(available)}" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/radioactivedecay/rdecay01/rdecay01.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/radioactivedecay/rdecay01/rdecay01.py new file mode 100644 index 00000000..ccff8acf --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/radioactivedecay/rdecay01/rdecay01.py @@ -0,0 +1,38 @@ +"""Python adaptation of extended/radioactivedecay/rdecay01.""" + +from __future__ import annotations + +import argparse +from collections import Counter + +import awkward as ak + +import geant4_python_application as g4 + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("-n", "--events", type=int, default=100) + p.add_argument("--batch", action="store_true") + a = p.parse_args() + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=137) as app: + app.add_physics("G4RadioactiveDecayPhysics") + app.initialize() # ion definitions must exist before /gun/ion + app.commands( + [ + "/gun/particle ion", + "/gun/ion 10 24 0 0", + "/gun/energy 0 keV", + "/gun/position 0 0 0 cm", + ] + ) + if not a.batch: + app.visualize() + return + events = app.run(a.events) + particles = Counter(ak.to_list(ak.flatten(events.track.particle, axis=None))) + print(f"events: {len(events)}, tracked particles: {dict(particles)}") + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/runAndEvent/RE01/re01.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/runAndEvent/RE01/re01.py new file mode 100644 index 00000000..8a8d4506 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/extended/runAndEvent/RE01/re01.py @@ -0,0 +1,49 @@ +"""Multi-primary Python adaptation of extended/runAndEvent/RE01. + +Each Geant4 event contains two independent primary vertices. This exercises +the package's native nested-primary event support and preserves both vertices +in the returned Awkward event record. +""" + +from __future__ import annotations + +import argparse + +import awkward as ak + +import geant4_python_application as g4 + + +def primary(particle: str, x: float) -> dict: + return { + "particle": particle, + "energy": 1000.0, + "position": {"x": x, "y": 0.0, "z": -300.0}, + "direction": {"x": 0.0, "y": 0.0, "z": 1.0}, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-n", "--events", type=int, default=10) + parser.add_argument("--batch", action="store_true") + args = parser.parse_args() + source = ak.Array( + [ + {"primaries": [primary("gamma", -10.0), primary("e-", 10.0)]} + for _ in range(args.events) + ] + ) + + with g4.Application(gdml=g4.basic_gdml, seed=137) as app: + if not args.batch: + app.visualize() + return + events = app.run(source) + counts = ak.to_list(ak.num(events.primaries)) + print(f"events: {len(events)}, primary vertices: {sum(counts)}") + assert counts == [2] * args.events + + +if __name__ == "__main__": + main() diff --git a/examples/geant4-examples/Geant4-11.4.2-examples-python/run_gdml.py b/examples/geant4-examples/Geant4-11.4.2-examples-python/run_gdml.py new file mode 100644 index 00000000..40662bf9 --- /dev/null +++ b/examples/geant4-examples/Geant4-11.4.2-examples-python/run_gdml.py @@ -0,0 +1,43 @@ +"""Run a GDML detector with geant4-python-application and the Qt viewer.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import geant4_python_application as g4 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("gdml", type=Path, help="path to a GDML geometry file") + parser.add_argument("-n", "--events", type=int, default=100) + parser.add_argument("--batch", action="store_true") + parser.add_argument("--command", action="append", default=[], help="Geant4 command") + args = parser.parse_args() + + geometry = args.gdml.read_text() + commands = args.command or [ + "/gun/particle geantino", + "/gun/energy 1 GeV", + "/gun/direction 0 0 1", + ] + with g4.Application(gdml=geometry, seed=137) as app: + app.commands(commands) + if args.batch: + print(f"events simulated: {len(app.run(args.events))}") + else: + app.visualize( + [ + "/vis/open OGL", + "/vis/drawVolume", + "/vis/viewer/set/style wireframe", + "/vis/scene/add/trajectories smooth", + "/tracking/storeTrajectory 1", + "/vis/scene/endOfEventAction accumulate 100", + ] + ) + + +if __name__ == "__main__": + main() diff --git a/examples/qt_viewer.py b/examples/qt_viewer.py new file mode 100644 index 00000000..69cb6b59 --- /dev/null +++ b/examples/qt_viewer.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import geant4_python_application as g4 + + +def main(): + with g4.Application(gdml=g4.basic_gdml, seed=137) as app: + app.commands( + [ + "/gun/particle e-", + "/gun/energy 100 MeV", + "/gun/direction 0 0 -1", + "/gun/position 0 0 40 cm", + ] + ) + app.visualize() + + +if __name__ == "__main__": + main() diff --git a/licenses/CLHEP-COPYING b/licenses/CLHEP-COPYING new file mode 100644 index 00000000..94a9ed02 --- /dev/null +++ b/licenses/CLHEP-COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/licenses/CLHEP-COPYING.LESSER b/licenses/CLHEP-COPYING.LESSER new file mode 100644 index 00000000..65c5ca88 --- /dev/null +++ b/licenses/CLHEP-COPYING.LESSER @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/licenses/Expat-COPYING b/licenses/Expat-COPYING new file mode 100644 index 00000000..dcb45064 --- /dev/null +++ b/licenses/Expat-COPYING @@ -0,0 +1,22 @@ +Copyright (c) 1998, 1999, 2000 Thai Open Source Software Center Ltd + and Clark Cooper +Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006 Expat maintainers. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/licenses/Geant4-LICENSE b/licenses/Geant4-LICENSE new file mode 100644 index 00000000..7fbe7107 --- /dev/null +++ b/licenses/Geant4-LICENSE @@ -0,0 +1,73 @@ +Geant4 Software License +Version 1.0, 28 June 2006 + +Copyright (c) Copyright Holders of the Geant4 Collaboration, 1994-2006. +See http://cern.ch/geant4/license for details on the copyright holders. All +rights not expressly granted under this license are reserved. + +This software includes voluntary contributions made to Geant4. +See http://cern.ch/geant4 for more information on Geant4. + +Installation, use, reproduction, display, modification and redistribution of +this software, with or without modification, in source and binary forms, are +permitted on a non-exclusive basis. Any exercise of rights by you under this +license is subject to the following conditions: + +1. Redistributions of this software, in whole or in part, with or without + modification, must reproduce the above copyright notice and these license + conditions in this software, the user documentation and any other + materials provided with the redistributed software. + +2. The user documentation,if any,included with a redistribution,must include + the following notice:"This product includes software developed by Members + of the Geant4 Collaboration ( http://cern.ch/geant4 )." + If that is where third-party acknowledgments normally appear, this + acknowledgment must be reproduced in the modified version of this + software itself. + +3. The names "Geant4" and "The Geant4 toolkit" may not be used to endorse or + promote software,or products derived there from, except with prior written + permission by license@geant4.org. If this software is redistributed in + modified form, the name and reference of the modified version must be + clearly distinguishable from that of this software. + +4. You are under no obligation to provide anyone with any modifications of + this software that you may develop,including but not limited to bug fixes, + patches, upgrades or other enhancements or derivatives of the features, + functionality or performance of this software. However, if you publish or + distribute your modifications without contemporaneously requiring users + to enter into a separate written license agreement, then you are deemed + to have granted all Members and all Copyright Holders of the Geant4 + Collaboration a license to your modifications, including modifications + protected by any patent owned by you,under the conditions of this license. + +5. You may not include this software in whole or in part in any patent or + patent application in respect of any modification of this software + developed by you. + + +6. DISCLAIMER + + THIS SOFTWARE IS PROVIDED BY THE MEMBERS AND COPYRIGHT HOLDERS OF THE GEANT4 + COLLABORATION AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, IMPLIED WARRANTIES OF MERCHANTABILITY, OF + SATISFACTORY QUALITY, AND FITNESS FOR A PARTICULAR PURPOSE OR USE ARE + DISCLAIMED. THE MEMBERS OF THE GEANT4 COLLABORATION AND CONTRIBUTORS MAKE NO + REPRESENTATION THAT THE SOFTWARE AND MODIFICATIONS THEREOF,WILL NOT INFRINGE + ANY PATENT, COPYRIGHT, TRADE SECRET OR OTHER PROPRIETARY RIGHT. + +7. LIMITATION OF LIABILITY + + THE MEMBERS AND COPYRIGHT HOLDERS OF THE GEANT4 COLLABORATION AND + CONTRIBUTORS SHALL HAVE NO LIABILITY FOR DIRECT,INDIRECT,SPECIAL, INCIDENTAL, + CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES OF ANY CHARACTER INCLUDING, + WITHOUT LIMITATION, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, LOSS OF USE, + DATA OR PROFITS, OR BUSINESS INTERRUPTION, HOWEVER CAUSED AND ON ANY THEORY + OF CONTRACT, WARRANTY, TORT (INCLUDING NEGLIGENCE), PRODUCT LIABILITY OR + OTHERWISE, ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +8. This license shall terminate with immediate effect and without notice if + you fail to comply with any of the terms of this license, or if you + institute litigation against any Member or Copyright Holder of the Geant4 + Collaboration with regard to this software. diff --git a/licenses/PTL-LICENSE b/licenses/PTL-LICENSE new file mode 100644 index 00000000..a6707da8 --- /dev/null +++ b/licenses/PTL-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Jonathan R. Madsen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/licenses/Xerces-C-LICENSE b/licenses/Xerces-C-LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/licenses/Xerces-C-LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/licenses/Xerces-C-NOTICE b/licenses/Xerces-C-NOTICE new file mode 100644 index 00000000..46994eb8 --- /dev/null +++ b/licenses/Xerces-C-NOTICE @@ -0,0 +1,10 @@ + ========================================================================= + == NOTICE file corresponding to section 4(d) of the Apache License, == + == Version 2.0, in this case for the Apache Xerces distribution. == + ========================================================================= + + This product includes software developed by + The Apache Software Foundation (http://www.apache.org/). + + Portions of this software were originally based on the following: + - software copyright (c) 1999, IBM Corporation., http://www.ibm.com. diff --git a/licenses/zlib-LICENSE b/licenses/zlib-LICENSE new file mode 100644 index 00000000..3676638c --- /dev/null +++ b/licenses/zlib-LICENSE @@ -0,0 +1,22 @@ +Copyright notice: + + (C) 1995-2012 Jean-loup Gailly and Mark Adler + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu diff --git a/pyproject.toml b/pyproject.toml index 5258efd6..c3c5aadc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,11 +50,18 @@ dynamic = ["version"] metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" sdist.include = ["src/geant4_python_application/_version.py"] wheel.expand-macos-universal-tags = true +# Ship the notices of the third-party libraries bundled into the wheels. +wheel.license-files = ["LICENSE", "NOTICE", "licenses/*"] [tool.setuptools_scm] # Section required write_to = "src/geant4_python_application/_version.py" [project.optional-dependencies] +# Qt for the interactive viewer in the published wheels. Only Application.visualize() +# loads it; source builds use the Qt that their Geant4 was built against instead. +gui = [ + "PyQt5-Qt5>=5.15.2,<5.16", +] dev = [ "boost_histogram", "hist", diff --git a/src/geant4_application/include/geant4_application/Application.h b/src/geant4_application/include/geant4_application/Application.h index 17b8bb14..371cdf21 100644 --- a/src/geant4_application/include/geant4_application/Application.h +++ b/src/geant4_application/include/geant4_application/Application.h @@ -44,8 +44,12 @@ class Application { void SetRandomSeed(long seed); void SetupManager(unsigned short nThreads); + static bool MultithreadingAvailable(); void SetupDetector(const std::string& gdml); - void SetupPhysics(); + void SetupPhysics(const std::string& physicsListName = "custom", bool optical = false); + static std::vector GetAvailablePhysicsLists(); + void AddExtraPhysics(const std::string& constructorName); + static std::vector GetAvailableExtraPhysics(); void SetupAction(); void Initialize(); diff --git a/src/geant4_application/include/geant4_application/DetectorConstruction.h b/src/geant4_application/include/geant4_application/DetectorConstruction.h index 3a2d79ea..fa8b4e6b 100644 --- a/src/geant4_application/include/geant4_application/DetectorConstruction.h +++ b/src/geant4_application/include/geant4_application/DetectorConstruction.h @@ -8,8 +8,11 @@ #include #include #include +#include #include +class G4GlobalMagFieldMessenger; + namespace geant4_app { class DetectorConstruction : public G4VUserDetectorConstruction { @@ -25,6 +28,12 @@ class DetectorConstruction : public G4VUserDetectorConstruction { void SetSensitiveVolumes(const std::set& sensitiveVolumes); std::set GetSensitiveVolumes() const { return sensitiveVolumes; } + void SetMagneticField(const std::array& fieldTesla); + std::array GetMagneticField() const { return magneticFieldTesla; } + + void SetElectricField(const std::array& fieldKVperCM); + std::array GetElectricField() const { return electricFieldKVperCM; } + std::string GetGDML() const { return gdml; } void SetGDML(const std::string& gdml); @@ -44,6 +53,10 @@ class DetectorConstruction : public G4VUserDetectorConstruction { std::set sensitiveVolumes; bool sensitiveDetectorConstructed = false; + std::array magneticFieldTesla = {0., 0., 0.}; + std::array electricFieldKVperCM = {0., 0., 0.}; + bool fieldConstructed = false; + static G4ThreadLocal G4GlobalMagFieldMessenger* magneticFieldMessenger; }; }// namespace geant4_app diff --git a/src/geant4_application/include/geant4_application/PhysicsList.h b/src/geant4_application/include/geant4_application/PhysicsList.h index c8fab885..d9008510 100644 --- a/src/geant4_application/include/geant4_application/PhysicsList.h +++ b/src/geant4_application/include/geant4_application/PhysicsList.h @@ -17,6 +17,6 @@ namespace geant4_app { class PhysicsList : public G4VModularPhysicsList { public: - PhysicsList(); + explicit PhysicsList(bool optical = false); }; }// namespace geant4_app diff --git a/src/geant4_application/include/geant4_application/PrimaryGeneratorAction.h b/src/geant4_application/include/geant4_application/PrimaryGeneratorAction.h index 7f8c1ce4..1b37ec3e 100644 --- a/src/geant4_application/include/geant4_application/PrimaryGeneratorAction.h +++ b/src/geant4_application/include/geant4_application/PrimaryGeneratorAction.h @@ -30,6 +30,7 @@ class PrimaryGeneratorAction : public G4VUserPrimaryGeneratorAction { inline static void SetAwkwardPrimaryPositions(const std::vector>& positions) { awkwardPrimaryPositions = positions; } inline static void SetAwkwardPrimaryDirections(const std::vector>& directions) { awkwardPrimaryDirections = directions; } inline static void SetAwkwardPrimaryParticles(const std::vector& particles) { awkwardPrimaryParticles = particles; } + inline static void SetAwkwardPrimaryEventOffsets(const std::vector& offsets) { awkwardPrimaryEventOffsets = offsets; } static void ClearAwkwardPrimaries(); private: @@ -41,6 +42,7 @@ class PrimaryGeneratorAction : public G4VUserPrimaryGeneratorAction { static std::vector> awkwardPrimaryPositions; static std::vector> awkwardPrimaryDirections; static std::vector awkwardPrimaryParticles; + static std::vector awkwardPrimaryEventOffsets; }; }// namespace geant4_app diff --git a/src/geant4_application/src/ActionInitialization.cpp b/src/geant4_application/src/ActionInitialization.cpp index 00ab70f3..6b3d50e7 100644 --- a/src/geant4_application/src/ActionInitialization.cpp +++ b/src/geant4_application/src/ActionInitialization.cpp @@ -8,6 +8,8 @@ #include "geant4_application/SteppingVerbose.h" #include "geant4_application/TrackingAction.h" +#include +#include #include using namespace std; @@ -27,7 +29,8 @@ void ActionInitialization::Build() const { SetUserAction(new StackingAction); SetUserAction(new TrackingAction); - // G4EventManager::GetEventManager()->SetNumberOfAdditionalWaitingStacks(1); // optical stack + // Required for optical photons (scintillation/Cerenkov use an extra stack). + G4RunManager::GetRunManager()->SetNumberOfAdditionalWaitingStacks(1); } G4VSteppingVerbose* ActionInitialization::InitializeSteppingVerbose() const { diff --git a/src/geant4_application/src/Application.cpp b/src/geant4_application/src/Application.cpp index 9e97a013..7be11ab7 100644 --- a/src/geant4_application/src/Application.cpp +++ b/src/geant4_application/src/Application.cpp @@ -6,8 +6,28 @@ #include "geant4_application/RunAction.h" #include "geant4_application/SteppingVerbose.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include #include #include @@ -48,7 +68,7 @@ void Application::SetupDetector(const string& gdml) { runManager->SetUserInitialization(new DetectorConstruction(gdml)); } -void Application::SetupPhysics() { +void Application::SetupPhysics(const string& physicsListName, bool optical) { if (G4RunManager::GetRunManager() == nullptr) { throw runtime_error("The run manager needs to be set up before the physics list"); } @@ -58,11 +78,90 @@ void Application::SetupPhysics() { } delete runManager->GetUserPhysicsList(); - auto physics = new PhysicsList(); + G4VUserPhysicsList* physics = nullptr; + if (physicsListName.empty() || physicsListName == "custom") { + physics = new PhysicsList(optical); + } else { + G4PhysListFactory factory; + if (!factory.IsReferencePhysList(physicsListName)) { + throw runtime_error("Unknown Geant4 reference physics list: " + physicsListName); + } + physics = factory.GetReferencePhysList(physicsListName); + if (optical) { + auto modular = dynamic_cast(physics); + if (modular == nullptr) { + delete physics; + throw runtime_error("Optical physics requires a modular physics list"); + } + modular->RegisterPhysics(new G4OpticalPhysics()); + } + } physics->SetVerboseLevel(0); runManager->SetUserInitialization(physics); } +vector Application::GetAvailablePhysicsLists() { + G4PhysListFactory factory; + const auto available = factory.AvailablePhysLists(); + vector names = {"custom"}; + names.reserve(available.size() + 1); + for (const auto& name: available) { + names.emplace_back(name.c_str()); + } + return names; +} + +static G4VPhysicsConstructor* CreateExtraPhysics(const string& name) { + if (name == "G4OpticalPhysics") return new G4OpticalPhysics(); + if (name == "G4EmExtraPhysics") return new G4EmExtraPhysics(); + if (name == "G4RadioactiveDecayPhysics") return new G4RadioactiveDecayPhysics(); + if (name == "G4DecayPhysics") return new G4DecayPhysics(); + if (name == "G4EmDNAPhysics") return new G4EmDNAPhysics(); + if (name == "G4EmDNAPhysics_option1") return new G4EmDNAPhysics_option1(); + if (name == "G4EmDNAPhysics_option2") return new G4EmDNAPhysics_option2(); + if (name == "G4EmDNAPhysics_option3") return new G4EmDNAPhysics_option3(); + if (name == "G4EmDNAPhysics_option4") return new G4EmDNAPhysics_option4(); + if (name == "G4EmDNAPhysics_option5") return new G4EmDNAPhysics_option5(); + if (name == "G4EmDNAPhysics_option6") return new G4EmDNAPhysics_option6(); + if (name == "G4EmDNAPhysics_option7") return new G4EmDNAPhysics_option7(); + if (name == "G4EmDNAPhysics_option8") return new G4EmDNAPhysics_option8(); + if (name == "G4EmDNAChemistry") return new G4EmDNAChemistry(); + if (name == "G4EmDNAChemistry_option1") return new G4EmDNAChemistry_option1(); + if (name == "G4EmDNAChemistry_option2") return new G4EmDNAChemistry_option2(); + if (name == "G4EmDNAChemistry_option3") return new G4EmDNAChemistry_option3(); + return nullptr; +} + +vector Application::GetAvailableExtraPhysics() { + return {"G4OpticalPhysics", "G4EmExtraPhysics", "G4RadioactiveDecayPhysics", "G4DecayPhysics", + "G4EmDNAPhysics", "G4EmDNAPhysics_option1", "G4EmDNAPhysics_option2", "G4EmDNAPhysics_option3", + "G4EmDNAPhysics_option4", "G4EmDNAPhysics_option5", "G4EmDNAPhysics_option6", + "G4EmDNAPhysics_option7", "G4EmDNAPhysics_option8", + "G4EmDNAChemistry", "G4EmDNAChemistry_option1", "G4EmDNAChemistry_option2", "G4EmDNAChemistry_option3"}; +} + +void Application::AddExtraPhysics(const string& constructorName) { + if (G4RunManager::GetRunManager() == nullptr) { + throw runtime_error("The run manager needs to be set up before adding physics"); + } + if (runManager->GetUserPhysicsList() == nullptr) { + throw runtime_error("Setup physics before adding extra physics constructors"); + } + if (IsInitialized()) { + throw runtime_error("Extra physics cannot be added after initialization"); + } + auto* modular = dynamic_cast(const_cast(runManager->GetUserPhysicsList())); + if (modular == nullptr) { + throw runtime_error("Extra physics requires a modular physics list"); + } + G4VPhysicsConstructor* ctor = CreateExtraPhysics(constructorName); + if (ctor == nullptr) { + throw runtime_error("Unknown extra physics constructor: " + constructorName + + ". See available_extra_physics()"); + } + modular->RegisterPhysics(ctor); +} + void Application::SetupAction() { if (G4RunManager::GetRunManager() == nullptr) { throw runtime_error("The run manager needs to be set up before the action initialization"); @@ -88,13 +187,32 @@ void Application::SetupManager(unsigned short nThreads) { delete G4VSteppingVerbose::GetInstance(); SteppingVerbose::SetInstance(new SteppingVerbose); +#ifndef G4MULTITHREADED + if (nThreads > 0) { + throw runtime_error( + "This Geant4 installation was built without multithreading support; " + "use n_threads=0"); + } +#endif + const auto runManagerType = nThreads > 0 ? G4RunManagerType::MTOnly : G4RunManagerType::SerialOnly; runManager = unique_ptr(G4RunManagerFactory::CreateRunManager(runManagerType)); + // Instantiate the command-based scoring manager so /score/* UI commands + // are available to Python before initialization. + G4ScoringManager::GetScoringManager(); if (nThreads > 0) { runManager->SetNumberOfThreads((G4int) nThreads); } } +bool Application::MultithreadingAvailable() { +#ifdef G4MULTITHREADED + return true; +#else + return false; +#endif +} + void Application::Initialize() { if (runManager != nullptr && runManager->GetUserDetectorConstruction() != nullptr && runManager->GetUserPhysicsList() != nullptr && runManager->GetUserActionInitialization() == nullptr) { @@ -145,30 +263,42 @@ py::list Application::Run(const py::object& primaries) { const auto fields = py::cast(primaries.attr("fields")); const auto nEvents = py::cast(len_func(primaries)); - if (fields.contains("energy")) { - std::vector energies = py::cast>(primaries.attr("energy")); + py::object primaryRecords = primaries; + if (fields.contains("primaries")) { + primaryRecords = primaries.attr("primaries"); + auto countsObject = ak.attr("to_list")(ak.attr("num")(primaryRecords, py::arg("axis") = 1)); + auto counts = py::cast>(countsObject); + vector offsets = {0}; + for (const auto count: counts) offsets.push_back(offsets.back() + count); + PrimaryGeneratorAction::SetAwkwardPrimaryEventOffsets(offsets); + primaryRecords = ak.attr("flatten")(primaryRecords, py::arg("axis") = 1); + } + const auto primaryFields = py::cast(primaryRecords.attr("fields")); + + if (primaryFields.contains("energy")) { + std::vector energies = py::cast>(primaryRecords.attr("energy")); PrimaryGeneratorAction::SetAwkwardPrimaryEnergies(energies); } - if (fields.contains("particle")) { - std::vector particles = py::cast>(primaries.attr("particle")); + if (primaryFields.contains("particle")) { + std::vector particles = py::cast>(primaryRecords.attr("particle")); PrimaryGeneratorAction::SetAwkwardPrimaryParticles(particles); } - if (fields.contains("position")) { - std::vector positionX = py::cast>(primaries.attr("position")["x"]); - std::vector positionY = py::cast>(primaries.attr("position")["y"]); - std::vector positionZ = py::cast>(primaries.attr("position")["z"]); + if (primaryFields.contains("position")) { + std::vector positionX = py::cast>(primaryRecords.attr("position")["x"]); + std::vector positionY = py::cast>(primaryRecords.attr("position")["y"]); + std::vector positionZ = py::cast>(primaryRecords.attr("position")["z"]); std::vector> positions; - for (size_t i = 0; i < nEvents; i++) { + for (size_t i = 0; i < positionX.size(); i++) { positions.push_back({positionX[i], positionY[i], positionZ[i]}); } PrimaryGeneratorAction::SetAwkwardPrimaryPositions(positions); } - if (fields.contains("direction")) { - std::vector directionX = py::cast>(primaries.attr("direction")["x"]); - std::vector directionY = py::cast>(primaries.attr("direction")["y"]); - std::vector directionZ = py::cast>(primaries.attr("direction")["z"]); + if (primaryFields.contains("direction")) { + std::vector directionX = py::cast>(primaryRecords.attr("direction")["x"]); + std::vector directionY = py::cast>(primaryRecords.attr("direction")["y"]); + std::vector directionZ = py::cast>(primaryRecords.attr("direction")["z"]); std::vector> directions; - for (size_t i = 0; i < nEvents; i++) { + for (size_t i = 0; i < directionX.size(); i++) { directions.push_back({directionX[i], directionY[i], directionZ[i]}); } PrimaryGeneratorAction::SetAwkwardPrimaryDirections(directions); @@ -179,6 +309,7 @@ py::list Application::Run(const py::object& primaries) { } } + bool Application::IsSetup() const { return runManager != nullptr && runManager->GetUserDetectorConstruction() != nullptr && runManager->GetUserPhysicsList() != nullptr && runManager->GetUserActionInitialization() != nullptr; diff --git a/src/geant4_application/src/DataModel.cpp b/src/geant4_application/src/DataModel.cpp index 993e0365..04fd0ef2 100644 --- a/src/geant4_application/src/DataModel.cpp +++ b/src/geant4_application/src/DataModel.cpp @@ -42,18 +42,19 @@ void InsertEvent(const G4Event* event, Builders& builders) { } if (builders.fields.find("primaries") != builders.fields.end()) { builders.primaries.begin_list(); - const auto primaryVertex = event->GetPrimaryVertex(); - for (int i = 0; i < primaryVertex->GetNumberOfParticle(); i++) { - const auto primary = primaryVertex->GetPrimary(i); - const auto particle = primary->GetParticleDefinition(); - const auto energy = primary->GetKineticEnergy(); - const auto position = primaryVertex->GetPosition(); - const auto direction = primary->GetMomentumDirection(); + for (auto primaryVertex = event->GetPrimaryVertex(); primaryVertex != nullptr; primaryVertex = primaryVertex->GetNext()) { + for (int i = 0; i < primaryVertex->GetNumberOfParticle(); i++) { + const auto primary = primaryVertex->GetPrimary(i); + const auto particle = primary->GetParticleDefinition(); + const auto energy = primary->GetKineticEnergy(); + const auto position = primaryVertex->GetPosition(); + const auto direction = primary->GetMomentumDirection(); - builders.primaries.content().content<0>().append(particle->GetParticleName()); - builders.primaries.content().content<1>().append(energy / units::energy); - builders.primaries.content().content<2>().append(position / units::length); - builders.primaries.content().content<3>().append(direction); + builders.primaries.content().content<0>().append(particle->GetParticleName()); + builders.primaries.content().content<1>().append(energy / units::energy); + builders.primaries.content().content<2>().append(position / units::length); + builders.primaries.content().content<3>().append(direction); + } } builders.primaries.end_list(); } diff --git a/src/geant4_application/src/DetectorConstruction.cpp b/src/geant4_application/src/DetectorConstruction.cpp index 8dce9f7b..3eaa68d9 100644 --- a/src/geant4_application/src/DetectorConstruction.cpp +++ b/src/geant4_application/src/DetectorConstruction.cpp @@ -3,9 +3,19 @@ #include "geant4_application/Application.h" #include "geant4_application/SensitiveDetector.h" +#include +#include +#include +#include +#include +#include +#include #include +#include #include #include +#include +#include #include #include @@ -14,6 +24,30 @@ using namespace std; using namespace geant4_app; namespace fs = std::filesystem; +namespace { +// Uniform B (tesla) + E field for G4EqMagElectricField. +// Field value layout is [Bx,By,Bz,Ex,Ey,Ez] (see G4UniformElectricField). +class UniformEMField : public G4ElectroMagneticField { +public: + UniformEMField(const G4ThreeVector& b, const G4ThreeVector& e) : bField(b), eField(e) {} + void GetFieldValue(const G4double*, G4double* field) const override { + field[0] = bField.x(); + field[1] = bField.y(); + field[2] = bField.z(); + field[3] = eField.x(); + field[4] = eField.y(); + field[5] = eField.z(); + } + G4bool DoesFieldChangeEnergy() const override { return true; } + +private: + G4ThreeVector bField; + G4ThreeVector eField; +}; +}// namespace + +G4ThreadLocal G4GlobalMagFieldMessenger* DetectorConstruction::magneticFieldMessenger = nullptr; + DetectorConstruction::DetectorConstruction(const string& gdml) : G4VUserDetectorConstruction() { SetGDML(gdml); // TODO: store this string as compressed data? @@ -35,6 +69,20 @@ void DetectorConstruction::SetSensitiveVolumes(const set& volumes) { sensitiveVolumes = volumes; } +void DetectorConstruction::SetMagneticField(const array& fieldTesla) { + if (fieldConstructed) { + throw runtime_error("Magnetic field cannot be changed after initialization"); + } + magneticFieldTesla = fieldTesla; +} + +void DetectorConstruction::SetElectricField(const array& fieldKVperCM) { + if (fieldConstructed) { + throw runtime_error("Electric field cannot be changed after initialization"); + } + electricFieldKVperCM = fieldKVperCM; +} + G4VPhysicalVolume* DetectorConstruction::Construct() { G4GDMLParser parser; @@ -89,6 +137,42 @@ void DetectorConstruction::ConstructSDandField() { // TODO: Regions } sensitiveDetectorConstructed = true; + + const bool hasElectric = (electricFieldKVperCM[0] != 0. || electricFieldKVperCM[1] != 0. || electricFieldKVperCM[2] != 0.); + if (!hasElectric) { + const G4ThreeVector field( + magneticFieldTesla[0] * tesla, + magneticFieldTesla[1] * tesla, + magneticFieldTesla[2] * tesla); + magneticFieldMessenger = new G4GlobalMagFieldMessenger(field); + magneticFieldMessenger->SetVerboseLevel(0); + G4AutoDelete::Register(magneticFieldMessenger); + } else { + // Combined uniform B (tesla) + E (kV/cm) via custom EM field. + // Mirrors extended/field/field02 (G4EqMagElectricField + ClassicalRK4). + auto* emField = new UniformEMField( + G4ThreeVector( + magneticFieldTesla[0] * tesla, + magneticFieldTesla[1] * tesla, + magneticFieldTesla[2] * tesla), + G4ThreeVector( + electricFieldKVperCM[0] * kilovolt / cm, + electricFieldKVperCM[1] * kilovolt / cm, + electricFieldKVperCM[2] * kilovolt / cm)); + auto* equation = new G4EqMagElectricField(emField); + auto* stepper = new G4ClassicalRK4(equation, 8); + auto* driver = new G4MagInt_Driver(0.01 * mm, stepper, stepper->GetNumberOfVariables()); + auto* fieldManager = G4TransportationManager::GetTransportationManager()->GetFieldManager(); + fieldManager->SetDetectorField(emField); + fieldManager->SetFieldChangesEnergy(true); + fieldManager->SetChordFinder(new G4ChordFinder(driver)); + G4AutoDelete::Register(emField); + G4AutoDelete::Register(equation); + G4AutoDelete::Register(stepper); + // ChordFinder owns driver; do not double-register driver. + magneticFieldMessenger = nullptr; + } + fieldConstructed = true; } set DetectorConstruction::GetMaterialNames() { diff --git a/src/geant4_application/src/PhysicsList.cpp b/src/geant4_application/src/PhysicsList.cpp index 90b4ab11..d94b2cd1 100644 --- a/src/geant4_application/src/PhysicsList.cpp +++ b/src/geant4_application/src/PhysicsList.cpp @@ -9,13 +9,14 @@ #include #include #include +#include #include #include #include using namespace geant4_app; -PhysicsList::PhysicsList() : G4VModularPhysicsList() { +PhysicsList::PhysicsList(bool optical) : G4VModularPhysicsList() { SetVerboseLevel(1); // Decay physics @@ -41,4 +42,8 @@ PhysicsList::PhysicsList() : G4VModularPhysicsList() { // Neutron tracking cut RegisterPhysics(new G4NeutronTrackingCut()); + + if (optical) { + RegisterPhysics(new G4OpticalPhysics()); + } } diff --git a/src/geant4_application/src/PrimaryGeneratorAction.cpp b/src/geant4_application/src/PrimaryGeneratorAction.cpp index a56b7725..e3958967 100644 --- a/src/geant4_application/src/PrimaryGeneratorAction.cpp +++ b/src/geant4_application/src/PrimaryGeneratorAction.cpp @@ -16,6 +16,27 @@ using namespace geant4_app; PrimaryGeneratorAction::PrimaryGeneratorAction() : G4VUserPrimaryGeneratorAction() {} void PrimaryGeneratorAction::GeneratePrimaries(G4Event* event) { + if (!awkwardPrimaryEventOffsets.empty()) { + const auto eventId = static_cast(event->GetEventID()); + for (size_t i = awkwardPrimaryEventOffsets[eventId]; i < awkwardPrimaryEventOffsets[eventId + 1]; ++i) { + if (!awkwardPrimaryEnergies.empty()) gun.SetParticleEnergy(awkwardPrimaryEnergies[i] * keV); + if (!awkwardPrimaryPositions.empty()) { + const auto& p = awkwardPrimaryPositions[i]; + gun.SetParticlePosition(G4ThreeVector(p[0] * cm, p[1] * cm, p[2] * cm)); + } + if (!awkwardPrimaryDirections.empty()) { + const auto& d = awkwardPrimaryDirections[i]; + gun.SetParticleMomentumDirection(G4ThreeVector(d[0], d[1], d[2])); + } + if (!awkwardPrimaryParticles.empty()) { + auto* particle = G4ParticleTable::GetParticleTable()->FindParticle(awkwardPrimaryParticles[i]); + if (particle == nullptr) throw runtime_error("Primary particle not found: " + awkwardPrimaryParticles[i]); + gun.SetParticleDefinition(particle); + } + gun.GeneratePrimaryVertex(event); + } + return; + } if (!awkwardPrimaryEnergies.empty()) { const double energy = awkwardPrimaryEnergies[event->GetEventID()]; gun.SetParticleEnergy(energy * keV); @@ -47,9 +68,9 @@ void PrimaryGeneratorAction::GeneratePrimaries(G4Event* event) { } void PrimaryGeneratorAction::SetGeneratorType(const string& type) { - const auto allowedTypes = set({"gun", "gps", "python"}); + const auto allowedTypes = set({"gun", "gps"}); if (allowedTypes.find(type) == allowedTypes.end()) { - throw runtime_error("PrimaryGeneratorAction::SetGeneratorType - type must be 'gun', 'gps', or 'python'"); + throw runtime_error("PrimaryGeneratorAction::SetGeneratorType - type must be 'gun' or 'gps'"); } generatorType = type; } @@ -83,9 +104,11 @@ void PrimaryGeneratorAction::ClearAwkwardPrimaries() { awkwardPrimaryPositions.clear(); awkwardPrimaryDirections.clear(); awkwardPrimaryParticles.clear(); + awkwardPrimaryEventOffsets.clear(); } vector PrimaryGeneratorAction::awkwardPrimaryEnergies = {}; vector> PrimaryGeneratorAction::awkwardPrimaryPositions = {}; vector> PrimaryGeneratorAction::awkwardPrimaryDirections = {}; vector PrimaryGeneratorAction::awkwardPrimaryParticles = {}; +vector PrimaryGeneratorAction::awkwardPrimaryEventOffsets = {}; diff --git a/src/geant4_application/src/SteppingVerbose.cpp b/src/geant4_application/src/SteppingVerbose.cpp index 11dc7692..d1ff8ba1 100644 --- a/src/geant4_application/src/SteppingVerbose.cpp +++ b/src/geant4_application/src/SteppingVerbose.cpp @@ -12,8 +12,11 @@ SteppingVerbose::SteppingVerbose() : G4SteppingVerbose() {} void SteppingVerbose::TrackingStarted() { CopyState(); + // Optical stacking (scintillation/Cerenkov) can invoke TrackingStarted + // with CurrentStepNumber != 0 for resumed tracks; skip duplicate init + // record instead of aborting the run. if (fStep->GetTrack()->GetCurrentStepNumber() != 0) { - throw runtime_error("SteppingVerbose::TrackingStarted: fStep->GetTrack()->GetCurrentStepNumber() != 0"); + return; } data::InsertStep(fStep, RunAction::GetBuilder()); } diff --git a/src/geant4_python_application/__init__.py b/src/geant4_python_application/__init__.py index a6d16ad7..33b8cdc1 100644 --- a/src/geant4_python_application/__init__.py +++ b/src/geant4_python_application/__init__.py @@ -1,5 +1,10 @@ from __future__ import annotations +from geant4_python_application import cad as cad +from geant4_python_application import distributed as distributed +from geant4_python_application import geometry as geometry +from geant4_python_application import io as io +from geant4_python_application import optical as optical from geant4_python_application._geant4_application import ( awkward_version, geant4_version, @@ -14,6 +19,8 @@ from geant4_python_application.files.datasets import install_datasets from geant4_python_application.files.directories import application_directory from geant4_python_application.gdml import basic_gdml +from geant4_python_application.generator import Generator +from geant4_python_application.scoring import NativeScoringMesh, Scoring __version__ = version __version__tuple__ = version_tuple @@ -21,14 +28,22 @@ __all__ = [ "Application", "Detector", + "Generator", + "NativeScoringMesh", + "Scoring", "__version__", "__version__tuple__", "application_directory", "awkward_version", "basic_gdml", + "cad", "data_directory", + "distributed", "geant4_version", + "geometry", "install_datasets", + "io", + "optical", "pybind11_version", "version", ] diff --git a/src/geant4_python_application/application.py b/src/geant4_python_application/application.py index c9f6c3b1..efe5de61 100644 --- a/src/geant4_python_application/application.py +++ b/src/geant4_python_application/application.py @@ -1,8 +1,11 @@ from __future__ import annotations +import importlib.util import multiprocessing +import os import threading from collections import namedtuple +from pathlib import Path import awkward as ak @@ -33,6 +36,45 @@ } +_package_dir = Path(__file__).parent +# Written into wheels whose Qt-linked libraries were repointed at the Qt shipped +# in the PyQt5-Qt5 package (see tools/relink_qt_wheel.py). +_PIP_QT_MARKER = _package_dir / "_qt_from_pyqt5_qt5" +_PIP_QT_DIR = _package_dir.parent / "PyQt5" / "Qt5" + + +def _visualization_problem() -> str | None: + """Why the Qt viewer can't be opened, or None if it can. Never loads Qt.""" + if importlib.util.find_spec("geant4_python_application._geant4_vis") is None: + return ( + "This installation was built without the Qt viewer. Reinstall from " + "source with -Ccmake.define.GEANT4_PYTHON_APPLICATION_VISUALIZATION=ON " + "using a Geant4 built with Qt and OpenGL." + ) + if _PIP_QT_MARKER.exists() and not (_PIP_QT_DIR / "lib").is_dir(): + return ( + "The Qt viewer needs Qt from the 'gui' extra: " + "pip install 'geant4_python_application[gui]'" + ) + return None + + +def _visualize(app, commands: list[str]) -> None: + """Runs in the Geant4 child process, which then owns the Qt event loop.""" + problem = _visualization_problem() + if problem is not None: + raise RuntimeError(problem) + # A platform plugin only loads into the exact Qt build it was compiled for, + # so point Qt at the plugins shipped alongside the Qt libraries in use. + if _PIP_QT_MARKER.exists(): + os.environ["QT_PLUGIN_PATH"] = str(_PIP_QT_DIR / "plugins") + from geant4_python_application import _geant4_vis + + if not app.is_initialized(): + app.initialize() + _geant4_vis.start_visualization(commands) + + def _start_application(pipe: multiprocessing.Pipe): app = Geant4Application() app.set_event_fields(_default_event_fields) @@ -44,6 +86,12 @@ def _start_application(pipe: multiprocessing.Pipe): break counter, message = message_with_counter + if message.target == "" and message.method == "visualize": + # Handled here so the Qt viewer module is only ever imported in + # this process, and only when a viewer is actually requested. + _visualize(app, *message.args, **message.kwargs) + pipe.send((counter, None)) + continue target = app target_list = message.target.split(".") target_list = [element for element in target_list if element] @@ -70,7 +118,13 @@ def _start_application(pipe: multiprocessing.Pipe): class Application: def __init__( - self, n_threads: int = 0, gdml: str = None, physics=None, seed: int = 0 + self, + n_threads: int = 0, + gdml: str = None, + physics: str = "custom", + seed: int = 0, + *, + optical: bool = False, ): geant4_python_application.install_datasets(show_progress=True) @@ -83,8 +137,11 @@ def __init__( self._seed = seed self._detector = geant4_python_application.Detector(self) + self._generator = geant4_python_application.Generator(self) self._n_threads = n_threads self._gdml = gdml + self._physics = physics + self._optical = optical def start(self, setup: bool = True, initialize: bool = False) -> Application: if self._process.is_alive(): @@ -94,7 +151,7 @@ def start(self, setup: bool = True, initialize: bool = False) -> Application: if setup: self.seed = self._seed self.setup_manager(self._n_threads) - self.setup_physics() + self.setup_physics(self._physics, optical=self._optical) if self._gdml is not None: self.setup_detector(self._gdml) @@ -149,8 +206,28 @@ def setup_manager(self, n_threads: int = 0) -> Application: self._send_and_recv(Message("", "setup_manager", (n_threads,), {})) return self - def setup_physics(self) -> Application: - self._send_and_recv(Message("", "setup_physics", (), {})) + def setup_physics( + self, physics_list: str = "custom", *, optical: bool = False + ) -> Application: + self._send_and_recv(Message("", "setup_physics", (physics_list, optical), {})) + return self + + @staticmethod + def available_physics_lists() -> list[str]: + return list(Geant4Application.available_physics_lists()) + + @staticmethod + def available_extra_physics() -> list[str]: + """Extra G4VPhysicsConstructor names usable with add_physics (incl. DNA).""" + return list(Geant4Application.available_extra_physics()) + + def add_physics(self, constructor_name: str) -> Application: + """Register an extra physics constructor (e.g. DNA, optical, EM extra). + + Must be called after setup_physics and before initialize/run. + Example: app.add_physics("G4EmDNAPhysics_option2") + """ + self._send_and_recv(Message("", "add_physics", (constructor_name,), {})) return self def setup_detector(self, gdml: str) -> Application: @@ -253,6 +330,62 @@ def run(self, primaries: int | ak.Array): events = events[ak.argsort(events.id)] return events + def run_with_callbacks( + self, + primaries: int | ak.Array, + *, + on_run=None, + on_event=None, + on_track=None, + on_step=None, + ): + """Run then invoke Python callbacks over run/event/track/step records. + + Offline (post-run) layer over the awkward event model: + on_run(events), on_event(event) per event, on_track(track, event) + per track, on_step(step, track, event) per step. Returns events. + + Note: events.track is a struct-of-lists per event (see DataModel), + so tracks/steps are reconstructed via ak.zip per event/track. + """ + events = self.run(primaries) + if on_run is not None: + on_run(events) + if on_event is None and on_track is None and on_step is None: + return events + for ei in range(len(events)): + event = events[ei] + if on_event is not None: + on_event(event) + if on_track is None and on_step is None: + continue + if "track" not in event.fields: + continue + track_struct = event.track + if "id" not in track_struct.fields: + continue + n_tracks = len(track_struct.id) + track_only_fields = [f for f in track_struct.fields if f != "step"] + has_steps = "step" in track_struct.fields + for ti in range(n_tracks): + # Lightweight dict view (scalars/vectors); steps as awkward array. + track_view = {f: track_struct[f][ti] for f in track_only_fields} + steps_view = None + if has_steps: + step_struct = track_struct.step + steps_view = ak.zip( + {f: step_struct[f][ti] for f in step_struct.fields} + ) + track_view = dict(track_view) + track_view["step"] = steps_view + if on_track is not None: + on_track(track_view, event) + if on_step is None or steps_view is None: + continue + for step in steps_view: + on_step(step, track_view, event) + return events + @property def seed(self): return self._send_and_recv(Message("", "get_seed", (), {})) @@ -273,6 +406,37 @@ def commands(self, commands: list[str]) -> Application: def list_commands(self, directory="/") -> str: return self._send_and_recv(Message("", "list_commands", (directory,), {})) + @staticmethod + def visualization_available() -> bool: + """Whether the Qt viewer can be opened. Does not load Qt.""" + return _visualization_problem() is None + + @staticmethod + def multithreading_available() -> bool: + """Whether the linked Geant4 runtime supports multithreaded run managers.""" + return Geant4Application.multithreading_available() + + def visualize(self, commands: list[str] | None = None) -> Application: + """Open the interactive Geant4 Qt viewer. + + The call returns after the viewer is closed. Geant4 commands can be + entered in the Qt session's command panel while it is open. + """ + if commands is None: + commands = [ + "/vis/open OGL", + "/vis/drawVolume", + "/vis/viewer/set/autoRefresh true", + "/vis/scene/add/trajectories smooth", + "/vis/scene/endOfEventAction accumulate", + ] + self._send_and_recv(Message("", "visualize", (commands,), {})) + return self + @property def detector(self) -> geant4_python_application.Detector: return self._detector + + @property + def generator(self) -> geant4_python_application.Generator: + return self._generator diff --git a/src/geant4_python_application/cad.py b/src/geant4_python_application/cad.py new file mode 100644 index 00000000..b5c4e5f8 --- /dev/null +++ b/src/geant4_python_application/cad.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +"""CAD -> GDML helpers via tessellated solids (no external CAD kernel). + +Provides: +- mesh_to_gdml(vertices, faces): build GDML with a solid. +- stl_ascii_to_gdml(stl_text): parse ASCII STL -> GDML (triangles only). +Both produce a self-contained GDML string with world box + placed volume, +suitable for Application(gdml=...). +""" + + +def mesh_to_gdml( + vertices: list[tuple[float, float, float]], + faces: list[tuple[int, int, int]], + *, + name: str = "cad_solid", + material: str = "G4_WATER", + world_size_mm: float = 1000.0, + unit: str = "mm", +) -> str: + if len(vertices) == 0 or len(faces) == 0: + msg = "vertices and faces must be non-empty" + raise ValueError(msg) + pos_lines = [] + for i, (x, y, z) in enumerate(vertices): + pos_lines.append( + f' ' + ) + tri_lines = [] + for a, b, c in faces: + if min(a, b, c) < 0 or max(a, b, c) >= len(vertices): + msg = f"face index out of range: {(a, b, c)}" + raise ValueError(msg) + tri_lines.append( + f' ' + ) + positions = "\n".join(pos_lines) + triangles = "\n".join(tri_lines) + return f""" + + +{positions} + + + + + + + + +{triangles} + + + + + + + + + + + + + + + + + + +""" + + +def cube_mesh(size_mm: float = 100.0) -> tuple[list, list]: + """Return (vertices, faces) for an axis-aligned cube centred at origin.""" + h = size_mm / 2.0 + v = [ + (-h, -h, -h), + (h, -h, -h), + (h, h, -h), + (-h, h, -h), + (-h, -h, h), + (h, -h, h), + (h, h, h), + (-h, h, h), + ] + f = [ + (0, 1, 2), + (0, 2, 3), # -z + (4, 6, 5), + (4, 7, 6), # +z + (0, 4, 5), + (0, 5, 1), # -y + (3, 2, 6), + (3, 6, 7), # +y + (0, 3, 7), + (0, 7, 4), # -x + (1, 5, 6), + (1, 6, 2), # +x + ] + return v, f + + +def stl_ascii_to_gdml(stl_text: str, **kwargs) -> str: + """Parse minimal ASCII STL (facet normal/vertex lines) into GDML.""" + verts: list[tuple[float, float, float]] = [] + index: dict[tuple[float, float, float], int] = {} + faces: list[tuple[int, int, int]] = [] + cur: list[int] = [] + for line in stl_text.splitlines(): + s = line.strip().split() + if len(s) >= 4 and s[0] == "vertex": + p = (float(s[1]), float(s[2]), float(s[3])) + if p not in index: + index[p] = len(verts) + verts.append(p) + cur.append(index[p]) + if len(cur) == 3: + faces.append((cur[0], cur[1], cur[2])) + cur = [] + if not faces: + msg = "no facets parsed from STL text" + raise ValueError(msg) + return mesh_to_gdml(verts, faces, **kwargs) diff --git a/src/geant4_python_application/detector.py b/src/geant4_python_application/detector.py index fec0c1f0..21e50b99 100644 --- a/src/geant4_python_application/detector.py +++ b/src/geant4_python_application/detector.py @@ -47,6 +47,36 @@ def sensitive_volumes(self, volumes): Message("detector", "set_sensitive_volumes", (volumes,), {}) ) + @property + def magnetic_field(self) -> tuple[float, float, float]: + """Uniform global magnetic field vector in tesla.""" + return tuple( + self._application._send_and_recv( + Message("detector", "get_magnetic_field", (), {}) + ) + ) + + @magnetic_field.setter + def magnetic_field(self, field_tesla: tuple[float, float, float]): + self._application._send_and_recv( + Message("detector", "set_magnetic_field", (field_tesla,), {}) + ) + + @property + def electric_field(self) -> tuple[float, float, float]: + """Uniform global electric field vector in kV/cm.""" + return tuple( + self._application._send_and_recv( + Message("detector", "get_electric_field", (), {}) + ) + ) + + @electric_field.setter + def electric_field(self, field_kv_per_cm: tuple[float, float, float]): + self._application._send_and_recv( + Message("detector", "set_electric_field", (field_kv_per_cm,), {}) + ) + def material_from_volume(self, volume: str) -> str: return self._application._send_and_recv( Message("detector", "get_material_from_volume", (volume,), {}) diff --git a/src/geant4_python_application/distributed.py b/src/geant4_python_application/distributed.py new file mode 100644 index 00000000..240a1886 --- /dev/null +++ b/src/geant4_python_application/distributed.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +"""MPI / distributed helpers with graceful fallback (no hard mpi4py dep).""" + +import os + + +def get_rank_size() -> tuple[int, int]: + """Return (rank, size) from mpi4py if available, else env vars, else (0,1).""" + try: + from mpi4py import MPI # type: ignore + + comm = MPI.COMM_WORLD + return int(comm.Get_rank()), int(comm.Get_size()) + except Exception: + pass + for rank_var, size_var in [ + ("OMPI_COMM_WORLD_RANK", "OMPI_COMM_WORLD_SIZE"), + ("PMI_RANK", "PMI_SIZE"), + ("MPI_RANK", "MPI_SIZE"), + ]: + if rank_var in os.environ and size_var in os.environ: + try: + return int(os.environ[rank_var]), int(os.environ[size_var]) + except ValueError: + continue + return 0, 1 + + +def split_count(n_total: int, rank: int, size: int) -> tuple[int, int]: + """Return (start, count) for rank under even split of n_total events.""" + if n_total < 0 or rank < 0 or size <= 0 or rank >= size: + msg = f"invalid split args: n={n_total} rank={rank} size={size}" + raise ValueError(msg) + base, rem = divmod(n_total, size) + start = rank * base + min(rank, rem) + count = base + (1 if rank < rem else 0) + return start, count + + +def local_count(n_total: int) -> tuple[int, int, int]: + """Return (rank, size, count) for this process.""" + rank, size = get_rank_size() + _, count = split_count(n_total, rank, size) + return rank, size, count diff --git a/src/geant4_python_application/events.py b/src/geant4_python_application/events.py index bbb46fb4..c5aaa39e 100644 --- a/src/geant4_python_application/events.py +++ b/src/geant4_python_application/events.py @@ -23,8 +23,11 @@ def hits(self, volume: str | None = None) -> ak.Array: hits = hits[hits.energy > 0] if volume is not None: hits = hits[hits.volume == volume] - # only keep x,y,z, time, and energy - hits = hits[["position", "time", "energy"]] + # Keep available hit fields; event-field selection may omit time or position. + fields = [ + field for field in ("position", "time", "energy") if field in hits.fields + ] + hits = hits[fields] return ak.Array(hits, with_name="Geant4Hits") diff --git a/src/geant4_python_application/files/datasets.py b/src/geant4_python_application/files/datasets.py index 503e4b64..4b03d4c7 100644 --- a/src/geant4_python_application/files/datasets.py +++ b/src/geant4_python_application/files/datasets.py @@ -7,6 +7,7 @@ import subprocess import tarfile import tempfile +import time from collections import namedtuple from pathlib import Path @@ -155,17 +156,37 @@ def _get_total_download_size(datasets_to_download: list[Dataset] = datasets) -> return sum(f.result() for f in concurrent.futures.as_completed(futures)) +chunk_size = 1024 * 1024 +max_attempts = 5 + + def _download_extract_dataset(dataset: Dataset, pbar: tqdm): filename = dataset.filename - urlpath = f"{url}/{filename}.{dataset.version}.tar.gz" - r = requests.get(urlpath, stream=True) - r.raise_for_status() + urlpath = _dataset_url(dataset) - chunk_size = 1024 with tempfile.TemporaryFile() as f: - for chunk in r.iter_content(chunk_size=chunk_size): - f.write(chunk) - pbar.update(chunk_size) + downloaded = 0 + for attempt in range(max_attempts): + # resume where the previous attempt died instead of restarting a multi-GB download + headers = {"Range": f"bytes={downloaded}-"} if downloaded else {} + try: + r = requests.get(urlpath, stream=True, headers=headers, timeout=60) + r.raise_for_status() + if downloaded and r.status_code != 206: + # server ignored the range request, so start over + f.seek(0) + f.truncate() + pbar.update(-downloaded) + downloaded = 0 + for chunk in r.iter_content(chunk_size=chunk_size): + f.write(chunk) + downloaded += len(chunk) + pbar.update(len(chunk)) + break + except requests.RequestException: + if attempt == max_attempts - 1: + raise + time.sleep(2**attempt) f.seek(0) md5 = hashlib.md5() @@ -249,7 +270,9 @@ def install_datasets(show_progress: bool = True): executor.submit(_download_extract_dataset, dataset, pbar) for dataset in datasets_to_download ] - concurrent.futures.wait(futures) + for future in concurrent.futures.as_completed(futures): + # surface download/extraction errors instead of reporting a partial install as success + future.result() if show_progress: total_size_gb = sum( diff --git a/src/geant4_python_application/files/directories.py b/src/geant4_python_application/files/directories.py index 7ffcfda4..c7eb80c0 100644 --- a/src/geant4_python_application/files/directories.py +++ b/src/geant4_python_application/files/directories.py @@ -14,7 +14,11 @@ _dirs = platformdirs.AppDirs(_app_name, _app_author) -_application_directory = _dirs.user_data_dir +# Shared by every environment of the user, so the ~2 GB of datasets download +# only once. GEANT4_PYTHON_APPLICATION_DIR overrides it, e.g. on clusters. +_application_directory = os.environ.get( + "GEANT4_PYTHON_APPLICATION_DIR", _dirs.user_data_dir +) def application_directory(path: str | None = None, *, temp: bool = False) -> str: diff --git a/src/geant4_python_application/generator.py b/src/geant4_python_application/generator.py new file mode 100644 index 00000000..fd592760 --- /dev/null +++ b/src/geant4_python_application/generator.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from geant4_python_application.application import Message + +if TYPE_CHECKING: + from geant4_python_application.application import Application + + +class Generator: + """Configure Geant4's particle gun or General Particle Source (GPS).""" + + def __init__(self, application: Application): + self._application = application + + @property + def type(self) -> str: + return self._application._send_and_recv( + Message("generator", "get_type", (), {}) + ) + + @type.setter + def type(self, generator_type: str): + self._application._send_and_recv( + Message("generator", "set_type", (generator_type,), {}) + ) + + def use_gun(self) -> Generator: + self.type = "gun" + return self + + def use_gps(self) -> Generator: + self.type = "gps" + return self + + def particle(self, name: str) -> Generator: + command = "/gps/particle" if self.type == "gps" else "/gun/particle" + self._application.command(f"{command} {name}") + return self + + def energy(self, value: float, unit: str = "MeV") -> Generator: + command = "/gps/ene/mono" if self.type == "gps" else "/gun/energy" + self._application.command(f"{command} {value} {unit}") + return self + + def position(self, x: float, y: float, z: float, unit: str = "cm") -> Generator: + if self.type == "gps": + self._application.command("/gps/pos/type Point") + self._application.command(f"/gps/pos/centre {x} {y} {z} {unit}") + else: + self._application.command(f"/gun/position {x} {y} {z} {unit}") + return self + + def direction(self, x: float, y: float, z: float) -> Generator: + command = "/gps/direction" if self.type == "gps" else "/gun/direction" + self._application.command(f"{command} {x} {y} {z}") + return self + + def commands(self, commands: list[str]) -> Generator: + """Apply advanced source commands such as GPS distributions.""" + self._application.commands(commands) + return self diff --git a/src/geant4_python_application/geometry.py b/src/geant4_python_application/geometry.py new file mode 100644 index 00000000..5fd659a0 --- /dev/null +++ b/src/geant4_python_application/geometry.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +"""Pure-Python GDML geometry generators. + +These helpers expand regular layouts into ordinary GDML placements. They are +useful where an official example uses a live ``G4VPVParameterisation`` callback: +the geometry is equivalent at initialization time, but there is no callback +during tracking. +""" + + +def linear_array_gdml( + count: int, + *, + spacing_mm: float = 20.0, + box_size_mm: float = 10.0, + material: str = "G4_Si", + world_material: str = "G4_AIR", +) -> str: + """Return GDML containing ``count`` equally spaced boxes along x.""" + if count < 1: + raise ValueError("count must be at least one") + if spacing_mm <= 0 or box_size_mm <= 0: + raise ValueError("spacing_mm and box_size_mm must be positive") + extent = (count - 1) * spacing_mm + box_size_mm + world_x = max(100.0, extent + 4.0 * spacing_mm) + placements = [] + x0 = -0.5 * (count - 1) * spacing_mm + for index in range(count): + x = x0 + index * spacing_mm + placements.append( + f''' + + + ''' + ) + return f''' + + + + + + + + + +{chr(10).join(placements)} + + + + +''' diff --git a/src/geant4_python_application/io.py b/src/geant4_python_application/io.py new file mode 100644 index 00000000..11286807 --- /dev/null +++ b/src/geant4_python_application/io.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +"""VTK / ROOT / external-generator helpers (pure Python, optional deps). + +- mesh_to_vtk: write Cartesian energy mesh to legacy VTK STRUCTURED_POINTS. +- events_to_parquet / events_from_parquet: always available (pyarrow). +- events_to_root / events_from_root: via uproot if installed. +- hepmc_to_primaries: minimal HepMC2-ASCII -> awkward primaries. +""" + +from pathlib import Path + +import awkward as ak +import numpy as np + + +def mesh_to_vtk(mesh: np.ndarray, edges: tuple, filename: str | Path) -> Path: + """Write (nx,ny,nz) energy mesh to legacy VTK STRUCTURED_POINTS file.""" + mesh = np.asarray(mesh, dtype=float) + if mesh.ndim != 3: + msg = f"mesh must be 3D, got shape {mesh.shape}" + raise ValueError(msg) + nx, ny, nz = mesh.shape + if len(edges) != 3: + msg = "edges must be (x_edges, y_edges, z_edges)" + raise ValueError(msg) + origins, spacings = [], [] + dims = [] + for n, e in zip((nx, ny, nz), edges): + e = np.asarray(e, dtype=float) + if len(e) != n + 1: + msg = f"edges length {len(e)} inconsistent with mesh dim {n}" + raise ValueError(msg) + origins.append(float(e[0])) + spacings.append(float(e[1] - e[0]) if n > 0 else 1.0) + dims.append(n + 1 if False else n) # points == cells for cell data + out = Path(filename) + # VTK Fortran ordering: x varies fastest + flat = mesh.ravel(order="F") + with out.open("w") as f: + f.write("# vtk DataFile Version 3.0\n") + f.write("geant4 energy mesh\n") + f.write("ASCII\n") + f.write("DATASET STRUCTURED_POINTS\n") + f.write(f"DIMENSIONS {nx} {ny} {nz}\n") + f.write(f"ORIGIN {origins[0]} {origins[1]} {origins[2]}\n") + f.write(f"SPACING {spacings[0]} {spacings[1]} {spacings[2]}\n") + f.write(f"POINT_DATA {nx * ny * nz}\n") + f.write("SCALARS energy_keV float 1\n") + f.write("LOOKUP_TABLE default\n") + for v in flat: + f.write(f"{v}\n") + return out + + +def events_to_parquet(events: ak.Array, filename: str | Path) -> Path: + out = Path(filename) + ak.to_parquet(events, out) + return out + + +def events_from_parquet(filename: str | Path) -> ak.Array: + return ak.from_parquet(Path(filename)) + + +def events_to_root( + events: ak.Array, filename: str | Path, treename: str = "events" +) -> Path: + """Write flat per-event summary + hits to ROOT via uproot (requires uproot).""" + try: + import uproot + except ImportError as e: + msg = "uproot is required for ROOT I/O: pip install uproot" + raise ImportError(msg) from e + out = Path(filename) + # Flatten what ROOT can store: per-event id + deposited energy + nhits. + # Full nested step records stay in parquet; ROOT holds analysis summary. + import geant4_python_application as g4 + + edep = np.asarray(g4.Scoring.energy_deposit(events), dtype=np.float64) + ids = ( + np.asarray(events.id, dtype=np.int64) + if "id" in events.fields + else np.arange(len(events)) + ) + with uproot.recreate(out) as f: + f[treename] = {"id": ids, "edep_keV": edep} + return out + + +def events_from_root(filename: str | Path, treename: str = "events") -> dict: + try: + import uproot + except ImportError as e: + msg = "uproot is required for ROOT I/O: pip install uproot" + raise ImportError(msg) from e + with uproot.open(f"{filename}:{treename}") as t: + return t.arrays(library="np") + + +_PDG_TO_G4 = { + 11: "e-", + -11: "e+", + 22: "gamma", + 13: "mu-", + -13: "mu+", + 2112: "neutron", + 2212: "proton", + 211: "pi+", + -211: "pi-", + 111: "pi0", + 321: "kaon+", + -321: "kaon-", + 12: "nu_e", + -12: "anti_nu_e", + 14: "nu_mu", + -14: "anti_nu_mu", +} + + +def hepmc_to_primaries(hepmc_text: str) -> ak.Array: + """Parse minimal HepMC2 ASCII (E/P lines) into awkward primaries. + + Expects lines like: P ... + Position defaults to origin; direction = p/|p|; energy = kinetic approx. + Unknown PDG codes fall back to geantino. + """ + records = [] + for line in hepmc_text.splitlines(): + s = line.strip().split() + if not s or s[0] != "P": + continue + if len(s) < 7: + continue + pdg = int(s[2]) + px, py, pz, e = (float(s[3]), float(s[4]), float(s[5]), float(s[6])) + p = float(np.sqrt(px * px + py * py + pz * pz)) + if p == 0: + continue + particle = _PDG_TO_G4.get(pdg, "geantino") + # awkward primary energy convention in this package: keV numbers? + # Application primaries use raw doubles passed to gun in keV? Keep MeV->keV. + records.append( + { + "particle": particle, + "energy": e * 1e6, # GeV -> keV (gun awkward path uses keV) + "direction": {"x": px / p, "y": py / p, "z": pz / p}, + "position": {"x": 0.0, "y": 0.0, "z": 0.0}, + } + ) + if not records: + msg = "no P lines parsed from HepMC text" + raise ValueError(msg) + return ak.Array(records) diff --git a/src/geant4_python_application/optical.py b/src/geant4_python_application/optical.py new file mode 100644 index 00000000..413f424b --- /dev/null +++ b/src/geant4_python_application/optical.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +"""Minimal optical-materials/surfaces helpers (GDML-based). + +Geant4 optical properties (RINDEX, scintillation, WLS, ...) and optical +surfaces (skin/border) are defined in GDML via , and +// tags. This module provides a +compact, fast-simulating water-scintillator GDML example plus UI-command +helpers for tuning optical processes at run time. +""" + +optical_water_gdml = R""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +# Compact, self-contained optical geometries used by the Python adaptations of +# Geant4's LXe and WLS examples. They intentionally model the transport +# concepts rather than the examples' custom PMT/sensitive-detector classes. +lxe_scintillator_gdml = R""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +wls_fiber_gdml = R""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + +#: Common optical-process UI commands for Geant4 11.2 (apply via app.commands(...)). +#: Verified against /process/optical/scintillation and /process/optical/cerenkov. +optical_tuning_commands = [ + "/process/optical/scintillation/setStackPhotons true", + "/process/optical/cerenkov/setMaxPhotons 100", + "/process/optical/verbose 1", +] + + +def scintillation_commands( + stack_photons: bool = True, max_cerenkov: int = 100 +) -> list[str]: + """Return UI commands tuning scintillation/Cerenkov at run time (Geant4 11.2).""" + return [ + f"/process/optical/scintillation/setStackPhotons {'true' if stack_photons else 'false'}", + f"/process/optical/cerenkov/setMaxPhotons {max_cerenkov}", + ] diff --git a/src/geant4_python_application/scoring.py b/src/geant4_python_application/scoring.py new file mode 100644 index 00000000..eddd8d90 --- /dev/null +++ b/src/geant4_python_application/scoring.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import awkward as ak +import numpy as np + +if TYPE_CHECKING: + from geant4_python_application.application import Application + + +class NativeScoringMesh: + """Configure a Geant4 command-based Cartesian scoring mesh. + + This uses Geant4's native ``G4ScoringManager`` commands. It is distinct + from :class:`Scoring`, which bins recorded steps in Python after a run. + """ + + def __init__( + self, + application: Application, + name: str = "mesh", + quantity: str = "eDep", + ): + self.application = application + self.name = name + self.quantity = quantity + self._closed = False + + def box( + self, + half_size: tuple[float, float, float], + bins: tuple[int, int, int], + *, + unit: str = "mm", + center: tuple[float, float, float] = (0.0, 0.0, 0.0), + ) -> NativeScoringMesh: + """Create a box mesh; ``half_size`` follows Geant4 mesh semantics.""" + if self._closed: + raise RuntimeError("scoring mesh is already closed") + sx, sy, sz = half_size + nx, ny, nz = bins + cx, cy, cz = center + self.application.commands( + [ + f"/score/create/boxMesh {self.name}", + f"/score/mesh/boxSize {sx} {sy} {sz} {unit}", + f"/score/mesh/nBin {nx} {ny} {nz}", + f"/score/mesh/translate/xyz {cx} {cy} {cz} {unit}", + f"/score/quantity/energyDeposit {self.quantity}", + "/score/close", + ] + ) + self._closed = True + return self + + def dump(self, path: str | Path, *, option: str = "") -> Path: + """Dump the native mesh quantity after a run and return its path.""" + if not self._closed: + raise RuntimeError("configure and close the scoring mesh before dumping") + output = Path(path).expanduser().resolve() + output.parent.mkdir(parents=True, exist_ok=True) + if " " in str(output): + raise ValueError("Geant4 scoring output paths cannot contain spaces") + suffix = f" {option}" if option else "" + self.application.command( + f"/score/dumpQuantityToFile {self.name} {self.quantity} {output}{suffix}" + ) + return output + + +class Scoring: + """Scoring operations over Geant4 event, track, and step records.""" + + @staticmethod + def energy_deposit(events: ak.Array, volume: str | None = None) -> ak.Array: + """Return deposited energy in keV for each event.""" + steps = events.track.step + energy = steps.energy + if volume is not None: + energy = energy[steps.volume == volume] + return ak.sum(ak.flatten(energy, axis=-1), axis=1) + + @staticmethod + def hits(events: ak.Array, volume: str | None = None) -> ak.Array: + """Return positive-energy steps, optionally restricted to a volume.""" + return events.hits(volume) + + @staticmethod + def energy_mesh( + events: ak.Array, + bins: int | tuple[int, int, int] = 10, + mesh_range: tuple[tuple[float, float], tuple[float, float], tuple[float, float]] + | None = None, + volume: str | None = None, + ) -> tuple[np.ndarray, tuple[np.ndarray, np.ndarray, np.ndarray]]: + """Bin step energy into a Cartesian 3D mesh. + + Positions are in millimetres and mesh values are deposited energy in keV. + """ + hits = Scoring.hits(events, volume) + weights = ak.to_numpy(ak.flatten(hits.energy, axis=None)) + if len(weights) == 0: + shape = (bins, bins, bins) if isinstance(bins, int) else bins + return np.zeros(shape), (np.array([]), np.array([]), np.array([])) + + points = np.column_stack( + [ + ak.to_numpy(ak.flatten(hits.position.x, axis=None)), + ak.to_numpy(ak.flatten(hits.position.y, axis=None)), + ak.to_numpy(ak.flatten(hits.position.z, axis=None)), + ] + ) + mesh, edges = np.histogramdd( + points, bins=bins, range=mesh_range, weights=weights + ) + return mesh, edges diff --git a/src/python/module.cpp b/src/python/module.cpp index 80043512..aa6441b2 100644 --- a/src/python/module.cpp +++ b/src/python/module.cpp @@ -19,8 +19,13 @@ PYBIND11_MODULE(_geant4_application, m) { py::class_(m, "Application") .def(py::init<>()) .def("setup_manager", &Application::SetupManager, py::arg("n_threads")) + .def_static("multithreading_available", &Application::MultithreadingAvailable) .def("setup_detector", &Application::SetupDetector, py::arg("gdml")) - .def("setup_physics", &Application::SetupPhysics) + .def("setup_physics", &Application::SetupPhysics, + py::arg("physics_list") = "custom", py::arg("optical") = false) + .def_static("available_physics_lists", &Application::GetAvailablePhysicsLists) + .def("add_physics", &Application::AddExtraPhysics, py::arg("constructor_name")) + .def_static("available_extra_physics", &Application::GetAvailableExtraPhysics) .def("setup_action", &Application::SetupAction) .def("initialize", &Application::Initialize) .def("run", &Application::Run, py::arg("n_events")) @@ -44,6 +49,9 @@ PYBIND11_MODULE(_geant4_application, m) { [](const py::object&, const string& type) { return PrimaryGeneratorAction::SetGeneratorType(type); }) + .def_static("get_type", &PrimaryGeneratorAction::GetGeneratorType) + .def_static("set_type", &PrimaryGeneratorAction::SetGeneratorType, + py::arg("type")) .def_static("set_energy", &PrimaryGeneratorAction::SetEnergy, py::arg("energy")) .def_static("set_position", &PrimaryGeneratorAction::SetPosition, py::arg("position")) .def_static("set_direction", &PrimaryGeneratorAction::SetDirection, py::arg("direction")) @@ -56,6 +64,12 @@ PYBIND11_MODULE(_geant4_application, m) { .def_static("get_physical_volumes", &DetectorConstruction::GetPhysicalVolumeNames) .def("get_sensitive_volumes", &DetectorConstruction::GetSensitiveVolumes) .def("set_sensitive_volumes", &DetectorConstruction::SetSensitiveVolumes, py::arg("volumes")) + .def("get_magnetic_field", &DetectorConstruction::GetMagneticField) + .def("set_magnetic_field", &DetectorConstruction::SetMagneticField, + py::arg("field_tesla")) + .def("get_electric_field", &DetectorConstruction::GetElectricField) + .def("set_electric_field", &DetectorConstruction::SetElectricField, + py::arg("field_kV_per_cm")) .def_static("get_physical_volumes_from_logical_volume", &DetectorConstruction::GetPhysicalVolumesFromLogicalVolume, py::arg("logical_volume")) .def_static("get_logical_volume_from_physical_volume", &DetectorConstruction::GetLogicalVolumeFromPhysicalVolume, py::arg("physical_volume")) .def_static("get_material_from_volume", &DetectorConstruction::GetMaterialFromVolume, py::arg("volume")) diff --git a/src/vis/module.cpp b/src/vis/module.cpp new file mode 100644 index 00000000..11c01f56 --- /dev/null +++ b/src/vis/module.cpp @@ -0,0 +1,52 @@ +// The interactive Qt viewer, built as its own extension module so that only +// calling Application.visualize() loads Qt; importing the package never does. +// It drives the Geant4 instance set up by _geant4_application through Geant4's +// singleton managers, which both modules share via the shared Geant4 libraries. + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace py = pybind11; + +namespace { + +void StartVisualization(const std::vector& commands) { + if (G4RunManager::GetRunManager() == nullptr) { + throw std::runtime_error("Geant4 run manager is not set up; start the Application first"); + } + + int argc = 1; + char applicationName[] = "geant4-python-application"; + char* argv[] = {applicationName, nullptr}; + + auto visualization = std::make_unique(); + visualization->Initialize(); + + auto ui = std::make_unique(argc, argv, "qt"); + auto* uiManager = G4UImanager::GetUIpointer(); + for (const auto& command: commands) { + const int code = uiManager->ApplyCommand(command); + if (code != 0) { + throw std::runtime_error("Command '" + command + "' failed with code " + std::to_string(code)); + } + } + ui->SessionStart(); +} + +}// namespace + +PYBIND11_MODULE(_geant4_vis, m) { + m.doc() = "Interactive Geant4 Qt viewer for geant4_python_application"; + m.def("start_visualization", &StartVisualization, py::arg("commands"), + "Open the Qt viewer and block until its window is closed."); +} diff --git a/tests/test_analysis.py b/tests/test_analysis.py index c05e7eeb..59da7177 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -7,7 +7,7 @@ import pytest import requests -from geant4_python_application import Application +from geant4_python_application import Application, Scoring complexGdml = requests.get( "https://raw.githubusercontent.com/rest-for-physics/restG4/dc3a8f42cea4978206a13325261fa85ec1b26261/examples/13.IAXO/geometry/setup.gdml" @@ -80,6 +80,8 @@ def test_sensitive(): ) @pytest.mark.parametrize("n_threads", [0, 8]) def test_photoelectric(n_threads): + if n_threads > 0 and not Application.multithreading_available(): + pytest.skip("linked Geant4 runtime was built without multithreading") with Application(gdml=complexGdml, seed=1234, n_threads=n_threads) as app: sensitive_volume = "gasVolume" @@ -152,4 +154,21 @@ def test_muons(): sensitive_energy = events.energy_in_volume(volume) assert np.isclose(np.average(sensitive_energy), 24.821580016670598, rtol=1e-1) - assert np.isclose(np.std(sensitive_energy), 16.09166011697945, rtol=1e-1) + assert np.isclose(np.std(sensitive_energy), 16.09166011697945, rtol=1e-1) + + +def test_scoring_helpers(): + with Application(gdml=complexGdml, seed=1234) as app: + app.set_event_fields( + {"id", "track_id", "step_energy", "step_volume", "step_position"} + ) + app.command("/gun/particle gamma") + app.command("/gun/energy 10 keV") + app.command("/gun/position 0 0 20 cm") + app.command("/gun/direction 0 0 -1") + events = app.run(10) + + assert len(Scoring.energy_deposit(events)) == 10 + mesh, edges = Scoring.energy_mesh(events, bins=(4, 5, 6)) + assert mesh.shape == (4, 5, 6) + assert len(edges) == 3 diff --git a/tests/test_application.py b/tests/test_application.py index 8a734633..5a8e20c8 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -37,6 +37,20 @@ def test_run(): assert len(events) == 100 +def test_reference_physics_list(): + assert "custom" in Application.available_physics_lists() + assert "FTFP_BERT" in Application.available_physics_lists() + with Application(gdml=basic_gdml, physics="FTFP_BERT") as app: + assert len(app.run(1)) == 1 + + +def test_optical_physics_and_magnetic_field(): + with Application(gdml=basic_gdml, optical=True) as app: + app.detector.magnetic_field = (0.0, 0.0, 1.5) + assert app.detector.magnetic_field == (0.0, 0.0, 1.5) + assert len(app.run(1)) == 1 + + def test_seed_single_thread(): with Application(gdml=basic_gdml, seed=1100) as app: app.command("/gun/particle e-") diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py new file mode 100644 index 00000000..92d61253 --- /dev/null +++ b/tests/test_capabilities.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import awkward as ak + +import geant4_python_application as g4 +from geant4_python_application import cad +from geant4_python_application import distributed as dist +from geant4_python_application import io as gio +from geant4_python_application import optical as opt + + +def test_electric_field(): + with g4.Application(gdml=g4.basic_gdml, seed=101) as app: + app.detector.magnetic_field = (0.0, 0.0, 1.0) + app.detector.electric_field = (0.0, 0.0, 0.5) + assert tuple(app.detector.magnetic_field) == (0.0, 0.0, 1.0) + assert tuple(app.detector.electric_field) == (0.0, 0.0, 0.5) + app.command("/gun/particle e-") + app.command("/gun/energy 5 MeV") + assert len(app.run(2)) == 2 + + +def test_optical_scintillation(): + with g4.Application(gdml=opt.optical_water_gdml, optical=True, seed=102) as app: + app.command("/gun/particle e-") + app.command("/gun/energy 10 MeV") + app.command("/gun/position 0 0 0 mm") + app.command("/gun/direction 1 0 0") + events = app.run(2) + assert len(events) == 2 + parts = set(ak.to_list(ak.flatten(events.track.particle, axis=None))) + assert "opticalphoton" in parts + assert len(opt.scintillation_commands()) == 2 + + +def test_lxe_and_wls_optical_materials(): + for gdml, particle, energy in ( + (opt.lxe_scintillator_gdml, "e-", "1 MeV"), + (opt.wls_fiber_gdml, "opticalphoton", "3.5 eV"), + ): + with g4.Application(gdml=gdml, optical=True, seed=110) as app: + app.command(f"/gun/particle {particle}") + app.command(f"/gun/energy {energy}") + app.command("/gun/position 0 0 0 mm") + app.command("/gun/direction 0 0 1") + events = app.run(1) + particles = ak.flatten(events.track.particle, axis=None) + assert bool(ak.any(particles == "opticalphoton")) + + +def test_expanded_parameterised_geometry(): + gdml = g4.geometry.linear_array_gdml(5, spacing_mm=15, box_size_mm=5) + assert gdml.count(' 0 and counts["step"] > 0 + + +def test_extra_physics_and_dna(): + assert "G4EmExtraPhysics" in g4.Application.available_extra_physics() + assert any("DNA" in x for x in g4.Application.available_extra_physics()) + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=104) as app: + app.add_physics("G4EmExtraPhysics") + app.command("/gun/particle gamma") + app.command("/gun/energy 1 MeV") + assert len(app.run(1)) == 1 + with g4.Application(gdml=g4.basic_gdml, physics="FTFP_BERT", seed=105) as app: + app.add_physics("G4EmDNAPhysics_option2") + app.command("/gun/particle e-") + app.command("/gun/energy 100 keV") + assert len(app.run(1)) == 1 + + +def test_cad_vtk_parquet_hepmc(tmp_path): + v, f = cad.cube_mesh(100.0) + gdml = cad.mesh_to_gdml(v, f) + assert "tessellated" in gdml + with g4.Application(gdml=gdml, seed=106) as app: + assert len(app.run(1)) == 1 + with g4.Application(gdml=g4.basic_gdml, seed=107) as app: + app.command("/gun/particle e-") + app.command("/gun/energy 5 MeV") + events = app.run(2) + mesh, edges = g4.Scoring.energy_mesh(events, bins=(3, 3, 3)) + vtk_path = gio.mesh_to_vtk(mesh, edges, tmp_path / "mesh.vtk") + assert "STRUCTURED_POINTS" in vtk_path.read_text()[:500] + pq = gio.events_to_parquet(events, tmp_path / "events.parquet") + assert len(gio.events_from_parquet(pq)) == len(events) + hepmc = "E 0 1 1\nP 1 22 0 0 1 1 0 1\nP 2 11 0.1 0 0.5 0.6 0.0005 1\n" + prim = gio.hepmc_to_primaries(hepmc) + assert len(prim) == 2 + + +def test_root_io(tmp_path): + uproot = __import__("importlib").util.find_spec("uproot") + if uproot is None: + return + with g4.Application(gdml=g4.basic_gdml, seed=108) as app: + app.command("/gun/particle gamma") + app.command("/gun/energy 1 MeV") + events = app.run(2) + rp = gio.events_to_root(events, tmp_path / "events.root") + assert rp.exists() + assert len(gio.events_from_root(rp)["id"]) == len(events) + + +def test_distributed_split(): + assert dist.get_rank_size() == (0, 1) + assert dist.split_count(10, 0, 2) == (0, 5) + assert dist.split_count(10, 1, 2) == (5, 5) + s0, c0 = dist.split_count(3, 0, 2) + _, c1 = dist.split_count(3, 1, 2) + assert c0 + c1 == 3 + assert s0 == 0 + + +def test_native_scoring_mesh(tmp_path): + with g4.Application(gdml=g4.basic_gdml, seed=109) as app: + mesh = g4.NativeScoringMesh(app, "testMesh", "edep") + mesh.box((250, 250, 250), (2, 2, 2)) + app.command("/gun/particle e-") + app.command("/gun/energy 1 MeV") + app.run(1) + output = mesh.dump(tmp_path / "mesh.csv") + assert output.exists() + assert output.stat().st_size > 0 diff --git a/tests/test_module.py b/tests/test_module.py index 3e9e2817..e0a88d1f 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -15,3 +15,4 @@ def test_imports(): assert geant4_python_application assert geant4_python_application.Application assert geant4_python_application.Detector + assert geant4_python_application.Generator diff --git a/tests/test_run.py b/tests/test_run.py index d92169fb..eac82675 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -36,3 +36,31 @@ def test_awkward_primaries(n_threads): event_primary_energy = ak.flatten(events.primaries.energy) assert np.allclose(event_primary_energy, primaries.energy) + + +def test_general_particle_source(): + with Application(gdml=basic_gdml) as app: + app.generator.use_gps().particle("geantino").energy(2, "MeV") + app.generator.position(0, 0, -10, "cm").direction(0, 0, 1) + events = app.run(2) + assert len(events) == 2 + assert np.allclose(ak.flatten(events.primaries.energy), 2000) + + +def test_multiple_primary_vertices_per_event(): + particle = lambda name, x: { + "particle": name, + "energy": 1000.0, + "direction": {"x": 0.0, "y": 0.0, "z": 1.0}, + "position": {"x": x, "y": 0.0, "z": 0.0}, + } + source = ak.Array( + [ + {"primaries": [particle("gamma", -1.0), particle("e-", 1.0)]}, + {"primaries": [particle("gamma", 0.0)]}, + ] + ) + with Application(gdml=basic_gdml) as app: + events = app.run(source) + assert ak.to_list(ak.num(events.primaries)) == [2, 1] + assert ak.to_list(events.primaries.particle) == [["gamma", "e-"], ["gamma"]] diff --git a/tools/audit_examples.py b/tools/audit_examples.py new file mode 100755 index 00000000..30af3b4b --- /dev/null +++ b/tools/audit_examples.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Inventory official Geant4 examples and execute every claimed Python port.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET +from pathlib import Path + + +def official_inventory(root: Path) -> dict: + targets = sorted( + p.parent.relative_to(root).as_posix() for p in root.rglob("CMakeLists.txt") + ) + gdml = sorted(root.rglob("*.gdml")) + standalone: list[str] = [] + fragments: list[str] = [] + invalid: list[str] = [] + for path in gdml: + relative = path.relative_to(root).as_posix() + try: + tree = ET.parse(path) + document = tree.getroot() + except (ET.ParseError, OSError): + invalid.append(relative) + continue + setup = document.find("setup") + if setup is not None and setup.find("world") is not None: + standalone.append(relative) + else: + fragments.append(relative) + return { + "root": str(root.resolve()), + "cmake_targets": targets, + "gdml": { + "total": len(gdml), + "standalone_candidates": standalone, + "fragments": fragments, + "invalid_or_external_entity": invalid, + }, + } + + +def python_ports(root: Path, events: int, timeout: int) -> list[dict]: + results: list[dict] = [] + for path in sorted(root.rglob("*.py")): + if path.name == "run_gdml.py": + results.append( + {"path": path.relative_to(root).as_posix(), "status": "utility"} + ) + continue + text = path.read_text(errors="replace") + if "--batch" not in text: + results.append( + { + "path": path.relative_to(root).as_posix(), + "status": "not-batch-testable", + } + ) + continue + command = [sys.executable, str(path.resolve()), "--batch", "-n", str(events)] + started = time.monotonic() + with tempfile.TemporaryDirectory(prefix="g4-port-audit-") as workdir: + try: + completed = subprocess.run( + command, + cwd=workdir, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + status = "passed" if completed.returncode == 0 else "failed" + result = { + "path": path.relative_to(root).as_posix(), + "status": status, + "returncode": completed.returncode, + "seconds": round(time.monotonic() - started, 3), + } + if completed.returncode: + result["stderr_tail"] = completed.stderr[-2000:] + results.append(result) + except subprocess.TimeoutExpired as error: + results.append( + { + "path": path.relative_to(root).as_posix(), + "status": "timeout", + "seconds": round(time.monotonic() - started, 3), + "stderr_tail": (error.stderr or "")[-2000:], + } + ) + return results + + +def markdown(report: dict) -> str: + inventory = report["official"] + results = report["python_ports"] + passed = sum(item["status"] == "passed" for item in results) + failed = sum(item["status"] in {"failed", "timeout"} for item in results) + lines = [ + "# Geant4 example compatibility audit", + "", + f"- Official CMake targets: {len(inventory['cmake_targets'])}", + f"- Official GDML files: {inventory['gdml']['total']}", + f"- Standalone GDML candidates: {len(inventory['gdml']['standalone_candidates'])}", + f"- Python ports passing: {passed}", + f"- Python ports failing/timing out: {failed}", + "", + "| Python port | Status | Seconds |", + "|---|---:|---:|", + ] + for item in results: + lines.append( + f"| `{item['path']}` | {item['status']} | {item.get('seconds', '')} |" + ) + lines.append("") + lines.append("Only entries marked `passed` are validated as runnable Python ports.") + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("official_root", type=Path) + parser.add_argument("python_root", type=Path) + parser.add_argument("--events", type=int, default=1) + parser.add_argument("--timeout", type=int, default=120) + parser.add_argument("--json", type=Path) + parser.add_argument("--markdown", type=Path) + args = parser.parse_args() + + report = { + "official": official_inventory(args.official_root), + "python_ports": python_ports(args.python_root, args.events, args.timeout), + } + if args.json: + args.json.parent.mkdir(parents=True, exist_ok=True) + args.json.write_text(json.dumps(report, indent=2) + "\n") + rendered = markdown(report) + if args.markdown: + args.markdown.parent.mkdir(parents=True, exist_ok=True) + args.markdown.write_text(rendered) + print(rendered, end="") + failed = any( + item["status"] in {"failed", "timeout"} for item in report["python_ports"] + ) + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build_macos_self_contained.sh b/tools/build_macos_self_contained.sh new file mode 100755 index 00000000..b2912080 --- /dev/null +++ b/tools/build_macos_self_contained.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +if [ "$(uname -s)" != "Darwin" ]; then + echo "This script builds the self-contained macOS wheel." >&2 + exit 2 +fi + +python_bin=${PYTHON:-python} +geant4_dir=${Geant4_DIR:-/usr/local/lib/cmake/Geant4} +output_dir=${1:-dist/self-contained} +raw_dir="$output_dir/raw" +repaired_dir="$output_dir/repaired" + +rm -rf "$raw_dir" "$repaired_dir" +mkdir -p "$raw_dir" "$repaired_dir" "$output_dir" +"$python_bin" -m pip install --quiet build delocate wheel +"$python_bin" -m build --wheel --outdir "$raw_dir" \ + -Ccmake.define.GEANT4_PYTHON_APPLICATION_VISUALIZATION=ON \ + -Ccmake.define.Geant4_DIR="$geant4_dir" + +wheel=$(find "$raw_dir" -maxdepth 1 -name '*.whl' -print | head -n 1) +if [ -z "$wheel" ]; then + echo "Wheel build did not produce an artifact." >&2 + exit 1 +fi + +# Bundle Geant4, Xerces-C, Expat and other dylibs, but leave Qt out: the wheel +# takes Qt from the PyQt5-Qt5 package (the [gui] extra) at runtime instead of +# the build machine's Qt, which relink_qt_wheel.py sets up. +"$python_bin" -m delocate.cmd.delocate_wheel \ + --exclude .framework/Versions/5/Qt -w "$repaired_dir" "$wheel" +"$python_bin" "$(dirname "$0")/relink_qt_wheel.py" "$repaired_dir"/*.whl -w "$output_dir" +rm -rf "$raw_dir" "$repaired_dir" +echo "Relocatable wheel written to $output_dir; install it with the [gui] extra for the viewer" diff --git a/tools/relink_qt_wheel.py b/tools/relink_qt_wheel.py new file mode 100644 index 00000000..b4175dd5 --- /dev/null +++ b/tools/relink_qt_wheel.py @@ -0,0 +1,142 @@ +"""Point a repaired wheel's Qt dependencies at the Qt shipped in PyQt5-Qt5. + +Run after delocate-wheel / auditwheel repair, which must be told to leave Qt +out of the wheel (``--exclude``). Every bundled binary that links Qt gets an +rpath to site-packages/PyQt5/Qt5/lib; on macOS any absolute Qt framework paths +(e.g. Homebrew's /opt/homebrew/opt/qt@5/...) are first rewritten to @rpath. +A marker file tells the package to use that Qt's plugins at runtime. + + python tools/relink_qt_wheel.py dist/repaired/pkg.whl -w dist/final +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +PACKAGE = "geant4_python_application" +MARKER = f"{PACKAGE}/_qt_from_pyqt5_qt5" +# Relative to site-packages, which is the root of an unpacked wheel. +QT_LIB_DIR = "PyQt5/Qt5/lib" + +# Absolute (e.g. Homebrew) or already @rpath-relative Qt framework references. +MACOS_QT_FRAMEWORK = re.compile(r"^\s+(\S*/(Qt\w+\.framework/Versions/5/Qt\w+))\s") +LINUX_QT_LIBRARY = re.compile(r"^libQt5\w+\.so\.5$") +BUNDLED_QT = re.compile(r"^(libQt5\w*|Qt[A-Z]\w*)([.-]|$)") + + +def run(*args: str) -> str: + result = subprocess.run(args, check=False, capture_output=True, text=True) + if result.returncode != 0: + sys.exit( + f"Command failed ({result.returncode}): {' '.join(args)}\n{result.stderr}{result.stdout}" + ) + return result.stdout + + +def unpack(wheel: Path, root: Path) -> None: + # Not 'wheel unpack': it rejects RECORD hashes that delocate leaves stale + # after retagging WHEEL. 'wheel pack' regenerates RECORD afterwards anyway. + with zipfile.ZipFile(wheel) as archive: + for info in archive.infolist(): + path = Path(archive.extract(info, root)) + mode = (info.external_attr >> 16) & 0o777 + if mode: + path.chmod(mode) + + +def binaries(root: Path): + for path in sorted(root.rglob("*")): + if path.is_file() and (path.suffix in {".so", ".dylib"} or ".so." in path.name): + yield path + + +def rpath_to_qt(root: Path, binary: Path, origin: str) -> str: + return f"{origin}/{os.path.relpath(root / QT_LIB_DIR, binary.parent)}" + + +def relink_macos(root: Path, binary: Path) -> bool: + load_commands = run("otool", "-L", str(binary)).splitlines()[1:] + references = [ + m.groups() for line in load_commands if (m := MACOS_QT_FRAMEWORK.match(line)) + ] + if not references: + return False + for old, framework in references: + if old != f"@rpath/{framework}": + run("install_name_tool", "-change", old, f"@rpath/{framework}", str(binary)) + rpath = rpath_to_qt(root, binary, "@loader_path") + rpaths = re.findall( + r"^\s+path (\S+) \(offset", run("otool", "-l", str(binary)), re.M + ) + # Absolute rpaths only point into the build machine (e.g. its Qt or Geant4). + for stale in (p for p in rpaths if p.startswith("/")): + run("install_name_tool", "-delete_rpath", stale, str(binary)) + if rpath not in rpaths: + run("install_name_tool", "-add_rpath", rpath, str(binary)) + # install_name_tool invalidates the ad-hoc signature, and arm64 macOS + # refuses to load unsigned code. + run("codesign", "--force", "--sign", "-", str(binary)) + return True + + +def relink_linux(root: Path, binary: Path) -> bool: + needed = run("patchelf", "--print-needed", str(binary)).split() + if not any(LINUX_QT_LIBRARY.match(name) for name in needed): + return False + entries = [ + e for e in run("patchelf", "--print-rpath", str(binary)).strip().split(":") if e + ] + rpath = rpath_to_qt(root, binary, "$ORIGIN") + if rpath not in entries: + run("patchelf", "--set-rpath", ":".join([*entries, rpath]), str(binary)) + return True + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "wheel", type=Path, help="wheel already repaired by delocate/auditwheel" + ) + parser.add_argument( + "-w", "--wheel-dir", type=Path, required=True, help="output directory" + ) + args = parser.parse_args() + + relink = relink_macos if sys.platform == "darwin" else relink_linux + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "wheel" + unpack(args.wheel, root) + + bundled = [ + str(p.relative_to(root)) + for p in root.rglob("*") + if BUNDLED_QT.match(p.name) + ] + if bundled: + sys.exit( + f"Qt was bundled into the wheel; exclude it during repair: {bundled}" + ) + + relinked = [b for b in binaries(root) if relink(root, b)] + if not relinked: + sys.exit( + "No bundled binary links Qt; was the wheel built with the viewer enabled?" + ) + + (root / MARKER).write_text("PyQt5-Qt5\n") + args.wheel_dir.mkdir(parents=True, exist_ok=True) + run(sys.executable, "-m", "wheel", "pack", str(root), "-d", str(args.wheel_dir)) + + for binary in relinked: + print(f"Qt -> {QT_LIB_DIR}: {binary.relative_to(root)}") + + +if __name__ == "__main__": + main() diff --git a/tools/smoke_viewer.py b/tools/smoke_viewer.py new file mode 100644 index 00000000..18355038 --- /dev/null +++ b/tools/smoke_viewer.py @@ -0,0 +1,55 @@ +"""CI check that an installed wheel's Qt viewer works with the Qt from the gui extra. + +Needs a display: run directly on macOS, under ``xvfb-run`` on Linux. +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import textwrap + +import geant4_python_application as g4 + +VIEWER_SECONDS = 45 + +if not g4.Application.visualization_available(): + sys.exit( + "Qt viewer unavailable: viewer module missing or the [gui] extra not installed" + ) + +# The viewer module must resolve every Qt library it links against. +subprocess.run( + [sys.executable, "-c", "from geant4_python_application import _geant4_vis"], + check=True, +) +print("viewer module loads") + +# Open the viewer; it blocks until its window is closed, so surviving the +# timeout means the window came up and stayed up. +viewer = subprocess.Popen( + [ + sys.executable, + "-c", + textwrap.dedent( + """ + import geant4_python_application as g4 + with g4.Application(gdml=g4.basic_gdml, seed=1) as app: + app.visualize() + """ + ), + ], + # Own process group, so the Geant4 child process is cleaned up too. + start_new_session=True, +) +try: + viewer.wait(timeout=VIEWER_SECONDS) +except subprocess.TimeoutExpired: + print(f"viewer still running after {VIEWER_SECONDS} s") +else: + sys.exit(f"viewer exited early with code {viewer.returncode}") +finally: + os.killpg(viewer.pid, signal.SIGKILL) + viewer.wait()