obdeect-propagation 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. obdeect_propagation-0.8.0/CMakeLists.txt +84 -0
  2. obdeect_propagation-0.8.0/PKG-INFO +49 -0
  3. obdeect_propagation-0.8.0/README.md +21 -0
  4. obdeect_propagation-0.8.0/cpp/bindings/core.cpp +26 -0
  5. obdeect_propagation-0.8.0/cpp/bindings/extinction_bindings.hpp +238 -0
  6. obdeect_propagation-0.8.0/cpp/bindings/profile_bindings.hpp +126 -0
  7. obdeect_propagation-0.8.0/cpp/bindings/propagator_bindings.hpp +111 -0
  8. obdeect_propagation-0.8.0/cpp/bindings/scattering_bindings.hpp +124 -0
  9. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/extinction.hpp +56 -0
  10. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/pipeline.hpp +207 -0
  11. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/profile.hpp +324 -0
  12. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/propagator.hpp +959 -0
  13. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/refraction.hpp +234 -0
  14. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/scattering.hpp +620 -0
  15. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/sky.hpp +151 -0
  16. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/streams.hpp +31 -0
  17. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/thinning.hpp +194 -0
  18. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/timing.hpp +103 -0
  19. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/transmission.hpp +318 -0
  20. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/version.hpp +13 -0
  21. obdeect_propagation-0.8.0/cpp/include/obdeect/propagation/wavelength.hpp +58 -0
  22. obdeect_propagation-0.8.0/cpp/tests/data/atm_trans_2000_1_10_0_0_2000.dat +815 -0
  23. obdeect_propagation-0.8.0/cpp/tests/data/atmprof_ecmwf_north_winter_fixed.dat +74 -0
  24. obdeect_propagation-0.8.0/cpp/tests/data/scattering_exponential.dat +1229 -0
  25. obdeect_propagation-0.8.0/cpp/tests/test_physics.cpp +380 -0
  26. obdeect_propagation-0.8.0/cpp/tests/test_pipeline.cpp +185 -0
  27. obdeect_propagation-0.8.0/cpp/tests/test_profile.cpp +205 -0
  28. obdeect_propagation-0.8.0/cpp/tests/test_propagator.cpp +1252 -0
  29. obdeect_propagation-0.8.0/cpp/tests/test_refraction.cpp +155 -0
  30. obdeect_propagation-0.8.0/cpp/tests/test_scattering.cpp +616 -0
  31. obdeect_propagation-0.8.0/cpp/tests/test_stream_contract.cpp +15 -0
  32. obdeect_propagation-0.8.0/cpp/tests/test_thinning.cpp +314 -0
  33. obdeect_propagation-0.8.0/cpp/tests/test_timing.cpp +103 -0
  34. obdeect_propagation-0.8.0/cpp/tests/test_transmission.cpp +282 -0
  35. obdeect_propagation-0.8.0/cpp/tests/test_version.cpp +14 -0
  36. obdeect_propagation-0.8.0/cpp/tools/propagate.cpp +277 -0
  37. obdeect_propagation-0.8.0/pyproject.toml +42 -0
  38. obdeect_propagation-0.8.0/python/obdeect/propagation/__init__.py +23 -0
  39. obdeect_propagation-0.8.0/python/obdeect/propagation/cli.py +23 -0
  40. obdeect_propagation-0.8.0/python/obdeect/propagation/convert.py +267 -0
  41. obdeect_propagation-0.8.0/python/obdeect/propagation/extinction.py +113 -0
  42. obdeect_propagation-0.8.0/python/obdeect/propagation/profile.py +108 -0
  43. obdeect_propagation-0.8.0/python/obdeect/propagation/propagation.py +145 -0
  44. obdeect_propagation-0.8.0/python/obdeect/propagation/scattering.py +86 -0
  45. obdeect_propagation-0.8.0/python/tests/test_convert.py +213 -0
  46. obdeect_propagation-0.8.0/python/tests/test_extinction.py +342 -0
  47. obdeect_propagation-0.8.0/python/tests/test_import.py +9 -0
  48. obdeect_propagation-0.8.0/python/tests/test_profile.py +171 -0
  49. obdeect_propagation-0.8.0/python/tests/test_propagate.py +842 -0
  50. obdeect_propagation-0.8.0/python/tests/test_scattering.py +173 -0
  51. obdeect_propagation-0.8.0/python/tests/test_stream_contract.py +30 -0
@@ -0,0 +1,84 @@
1
+ cmake_minimum_required(VERSION 3.20)
2
+ project(obdeect_propagation LANGUAGES CXX)
3
+
4
+ option(OBDEECT_PROPAGATION_BUILD_TESTING "Build the C++ tests" ON)
5
+ if(DEFINED SKBUILD_PLATLIB_DIR)
6
+ set(OBDEECT_PROPAGATION_BUILD_PYTHON_API_DEFAULT ON)
7
+ else()
8
+ set(OBDEECT_PROPAGATION_BUILD_PYTHON_API_DEFAULT OFF)
9
+ endif()
10
+ option(OBDEECT_PROPAGATION_BUILD_PYTHON_API "Build the Python extension" ${OBDEECT_PROPAGATION_BUILD_PYTHON_API_DEFAULT})
11
+ option(OBDEECT_PROPAGATION_BUILD_TOOLS "Build the obdeect propagation run command-line tool" ON)
12
+
13
+ # The stream component: the target of the combined build, else the stream sources next to
14
+ # this component (builds from the repository), else an installed package (sdist builds).
15
+ if(NOT TARGET obdeect::stream)
16
+ if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/../stream/CMakeLists.txt)
17
+ set(OBDEECT_STREAM_BUILD_TESTING OFF CACHE BOOL "" FORCE)
18
+ set(OBDEECT_STREAM_BUILD_TOOLS OFF CACHE BOOL "" FORCE)
19
+ set(OBDEECT_STREAM_BUILD_CAPI OFF CACHE BOOL "" FORCE)
20
+ set(OBDEECT_STREAM_BUILD_PYTHON_API OFF CACHE BOOL "" FORCE)
21
+ set(OBDEECT_STREAM_INSTALL OFF CACHE BOOL "" FORCE)
22
+ add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../stream ${CMAKE_CURRENT_BINARY_DIR}/stream)
23
+ message(STATUS "obdeect propagation: using the stream sources in ../stream")
24
+ else()
25
+ find_package(Python 3.14 COMPONENTS Interpreter)
26
+ if(Python_Interpreter_FOUND AND NOT obdeect_stream_DIR)
27
+ execute_process(COMMAND "${Python_EXECUTABLE}" -m obdeect.stream --cmake-dir
28
+ OUTPUT_VARIABLE obdeect_stream_DIR OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
29
+ endif()
30
+ find_package(obdeect_stream CONFIG REQUIRED)
31
+ message(STATUS "obdeect propagation: using the installed stream package ${obdeect_stream_VERSION} (${obdeect_stream_DIR})")
32
+ endif()
33
+ endif()
34
+
35
+ # Header-only, standard-library-only core.
36
+ add_library(obdeect_propagation_core INTERFACE)
37
+ target_include_directories(obdeect_propagation_core INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/cpp/include)
38
+ target_compile_features(obdeect_propagation_core INTERFACE cxx_std_20)
39
+ target_link_libraries(obdeect_propagation_core INTERFACE obdeect::stream)
40
+ # Identical floating-point results for any thread count and chunk size on one
41
+ # platform: no implicit fused multiply-add contraction.
42
+ target_compile_options(obdeect_propagation_core INTERFACE -ffp-contract=off)
43
+
44
+ function(obdeect_propagation_enable_warnings target)
45
+ target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic -Werror)
46
+ endfunction()
47
+
48
+ if(OBDEECT_PROPAGATION_BUILD_PYTHON_API)
49
+ find_package(Python 3.14 COMPONENTS Interpreter Development.Module REQUIRED)
50
+ execute_process(COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
51
+ OUTPUT_VARIABLE nanobind_DIR OUTPUT_STRIP_TRAILING_WHITESPACE
52
+ COMMAND_ERROR_IS_FATAL ANY)
53
+ find_package(nanobind CONFIG REQUIRED)
54
+ nanobind_add_module(_core NB_STATIC cpp/bindings/core.cpp)
55
+ target_link_libraries(_core PRIVATE obdeect_propagation_core)
56
+ obdeect_propagation_enable_warnings(_core)
57
+ install(TARGETS _core LIBRARY DESTINATION obdeect/propagation)
58
+ endif()
59
+
60
+ if(OBDEECT_PROPAGATION_BUILD_TOOLS)
61
+ add_executable(obdeect-propagation-run cpp/tools/propagate.cpp)
62
+ target_link_libraries(obdeect-propagation-run PRIVATE obdeect_propagation_core)
63
+ obdeect_propagation_enable_warnings(obdeect-propagation-run)
64
+ # Wheels: inside the package, run through `obdeect propagation run`.
65
+ if(DEFINED SKBUILD_PLATLIB_DIR)
66
+ install(TARGETS obdeect-propagation-run RUNTIME DESTINATION obdeect/propagation/bin)
67
+ endif()
68
+ endif()
69
+
70
+ if(OBDEECT_PROPAGATION_BUILD_TESTING)
71
+ enable_testing()
72
+ foreach(test_name version transmission thinning pipeline profile refraction timing
73
+ propagator scattering stream_contract physics)
74
+ add_executable(obdeect_propagation_test_${test_name} cpp/tests/test_${test_name}.cpp)
75
+ target_link_libraries(obdeect_propagation_test_${test_name} PRIVATE obdeect_propagation_core)
76
+ # check.hpp lives with the stream tests; tests are built from the repository only, never from an sdist.
77
+ target_include_directories(obdeect_propagation_test_${test_name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../stream/cpp/tests)
78
+ # Reference data: cpp/tests/data (see test_transmission.cpp).
79
+ target_compile_definitions(obdeect_propagation_test_${test_name}
80
+ PRIVATE OBDEECT_PROPAGATION_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/cpp/tests/data")
81
+ obdeect_propagation_enable_warnings(obdeect_propagation_test_${test_name})
82
+ add_test(NAME ${test_name} COMMAND obdeect_propagation_test_${test_name})
83
+ endforeach()
84
+ endif()
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: obdeect-propagation
3
+ Version: 0.8.0
4
+ Summary: Atmospheric propagation of Cherenkov and artificial-source photons
5
+ Author: obdeect contributors
6
+ License-Expression: BSD-3-Clause
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: C++
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Programming Language :: Python :: 3.15
12
+ Requires-Python: >=3.14
13
+ Requires-Dist: numpy>=2.3
14
+ Requires-Dist: obdeect-stream==0.8.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: nanobind>=3.1; extra == "dev"
17
+ Requires-Dist: pre-commit; extra == "dev"
18
+ Requires-Dist: pytest; extra == "dev"
19
+ Requires-Dist: ruff; extra == "dev"
20
+ Provides-Extra: docs
21
+ Requires-Dist: myst-parser; extra == "docs"
22
+ Requires-Dist: pydata-sphinx-theme; extra == "docs"
23
+ Requires-Dist: pyyaml; extra == "docs"
24
+ Requires-Dist: sphinx; extra == "docs"
25
+ Provides-Extra: plot
26
+ Requires-Dist: matplotlib>=3.8; extra == "plot"
27
+ Description-Content-Type: text/markdown
28
+
29
+ # obdeect-propagation
30
+
31
+ Atmospheric propagation for Cherenkov-telescope simulations: extinction, refraction and timing
32
+ from the refractive-index profile, optional single scattering, and early photon thinning. The
33
+ C++20 core uses only the standard library. It has a Python API and works on photon streams.
34
+
35
+ ```sh
36
+ pip install obdeect-propagation # this component only (also installs obdeect-stream)
37
+ pip install obdeect # all obdeect components
38
+ ```
39
+
40
+ Requires Python 3.14 or higher.
41
+
42
+ ## Commands
43
+
44
+ ```sh
45
+ obdeect propagation run --transmission atm.dat --site-altitude 2156 --seed 1 -i in.ps -o out.ps
46
+ obdeect propagation convert-table ... # convert an atmospheric transmission table
47
+ ```
48
+
49
+ Full documentation and the repository README: https://github.com/gammasim/obdeect
@@ -0,0 +1,21 @@
1
+ # obdeect-propagation
2
+
3
+ Atmospheric propagation for Cherenkov-telescope simulations: extinction, refraction and timing
4
+ from the refractive-index profile, optional single scattering, and early photon thinning. The
5
+ C++20 core uses only the standard library. It has a Python API and works on photon streams.
6
+
7
+ ```sh
8
+ pip install obdeect-propagation # this component only (also installs obdeect-stream)
9
+ pip install obdeect # all obdeect components
10
+ ```
11
+
12
+ Requires Python 3.14 or higher.
13
+
14
+ ## Commands
15
+
16
+ ```sh
17
+ obdeect propagation run --transmission atm.dat --site-altitude 2156 --seed 1 -i in.ps -o out.ps
18
+ obdeect propagation convert-table ... # convert an atmospheric transmission table
19
+ ```
20
+
21
+ Full documentation and the repository README: https://github.com/gammasim/obdeect
@@ -0,0 +1,26 @@
1
+ // Python.h must precede all standard headers (it sets feature-test macros such as
2
+ // _POSIX_C_SOURCE), so the nanobind headers come first.
3
+ #include <nanobind/nanobind.h>
4
+ #include <nanobind/stl/tuple.h>
5
+
6
+ #include "extinction_bindings.hpp"
7
+ #include "obdeect/propagation/version.hpp"
8
+ #include "profile_bindings.hpp"
9
+ #include "propagator_bindings.hpp"
10
+ #include "scattering_bindings.hpp"
11
+
12
+ #include <tuple>
13
+
14
+ namespace nb = nanobind;
15
+
16
+ NB_MODULE(_core, module) {
17
+ module.doc() = "obdeect propagation native core";
18
+ module.def("core_version", [] {
19
+ return std::make_tuple(obdeect::propagation::version_major, obdeect::propagation::version_minor,
20
+ obdeect::propagation::version_patch);
21
+ });
22
+ bind_extinction(module);
23
+ bind_profile(module);
24
+ bind_propagator(module);
25
+ bind_scattering(module);
26
+ }
@@ -0,0 +1,238 @@
1
+ #pragma once
2
+
3
+ // nanobind bindings of the atmosphere step: TransmissionTable and apply_extinction.
4
+ // Included by core.cpp only; the C++ core stays free of Python.
5
+
6
+ #include <nanobind/nanobind.h>
7
+ #include <nanobind/ndarray.h>
8
+ #include <nanobind/stl/string.h>
9
+ #include <nanobind/stl/tuple.h>
10
+
11
+ #include "obdeect/propagation/extinction.hpp"
12
+ #include "obdeect/propagation/pipeline.hpp"
13
+ #include "obdeect/propagation/thinning.hpp"
14
+ #include "obdeect/propagation/transmission.hpp"
15
+ #include "obdeect/propagation/wavelength.hpp"
16
+
17
+ #include "obdeect/stream/format.hpp"
18
+ #include "obdeect/stream/photons.hpp"
19
+
20
+ #include <algorithm>
21
+ #include <cstddef>
22
+ #include <cstdint>
23
+ #include <stdexcept>
24
+ #include <string>
25
+ #include <tuple>
26
+ #include <utility>
27
+ #include <vector>
28
+
29
+ namespace obdeect_propagation_bindings {
30
+
31
+ namespace nb = nanobind;
32
+
33
+ using DoubleArray = nb::ndarray<const double, nb::ndim<1>, nb::c_contig, nb::device::cpu>;
34
+ using FloatArray = nb::ndarray<const float, nb::ndim<1>, nb::c_contig, nb::device::cpu>;
35
+
36
+ // Hand a vector to Python without copying; the returned array owns the vector.
37
+ template <class T> nb::ndarray<nb::numpy, T, nb::ndim<1>> to_numpy(std::vector<T> values) {
38
+ if (values.capacity() == 0) {
39
+ values.reserve(1); // a non-null data pointer also for empty arrays
40
+ }
41
+ auto *holder = new std::vector<T>(std::move(values));
42
+ nb::capsule owner(holder, [](void *p) noexcept { delete static_cast<std::vector<T> *>(p); });
43
+ return nb::ndarray<nb::numpy, T, nb::ndim<1>>(holder->data(), {holder->size()}, owner);
44
+ }
45
+
46
+ inline std::vector<double> to_vector(const DoubleArray &array) {
47
+ return std::vector<double>(array.data(), array.data() + array.size());
48
+ }
49
+
50
+ inline void bind_table(nb::module_ &module) {
51
+ using obdeect::propagation::TransmissionTable;
52
+ nb::class_<TransmissionTable>(module, "TransmissionTable")
53
+ .def(nb::new_([](const std::string &path, double site_altitude_m) {
54
+ return TransmissionTable::from_file(path, site_altitude_m);
55
+ }),
56
+ nb::arg("path"), nb::arg("site_altitude_m"))
57
+ .def_prop_ro("observation_level_m", &TransmissionTable::observation_level_m)
58
+ .def_prop_ro("first_wavelength_nm", &TransmissionTable::first_wavelength_nm)
59
+ .def_prop_ro("last_wavelength_nm", &TransmissionTable::last_wavelength_nm)
60
+ .def(
61
+ "optical_depth",
62
+ [](const TransmissionTable &self, const DoubleArray &wavelength_nm,
63
+ const DoubleArray &height_m) {
64
+ if (height_m.size() != wavelength_nm.size()) {
65
+ throw std::invalid_argument("arrays must have the same length");
66
+ }
67
+ std::vector<double> out(wavelength_nm.size());
68
+ for (std::size_t i = 0; i < out.size(); ++i) {
69
+ out[i] = self.optical_depth<double>(wavelength_nm.data()[i], height_m.data()[i]);
70
+ }
71
+ return to_numpy(std::move(out));
72
+ },
73
+ nb::arg("wavelength_nm"), nb::arg("height_m"))
74
+ .def(
75
+ "transmission",
76
+ [](const TransmissionTable &self, const DoubleArray &wavelength_nm,
77
+ const DoubleArray &zem_m, const DoubleArray &airmass) {
78
+ const std::size_t n = wavelength_nm.size();
79
+ if (zem_m.size() != n || airmass.size() != n) {
80
+ throw std::invalid_argument("arrays must have the same length");
81
+ }
82
+ std::vector<double> out(n);
83
+ for (std::size_t i = 0; i < n; ++i) {
84
+ out[i] = self.transmission<double>(wavelength_nm.data()[i], zem_m.data()[i],
85
+ airmass.data()[i]);
86
+ }
87
+ return to_numpy(std::move(out));
88
+ },
89
+ nb::arg("wavelength_nm"), nb::arg("zem_m"), nb::arg("airmass"))
90
+ .def(
91
+ "transmission2",
92
+ [](const TransmissionTable &self, const DoubleArray &wavelength_nm,
93
+ const DoubleArray &zem_m, const DoubleArray &airmass, const DoubleArray &od_tel,
94
+ const DoubleArray &od_focus) {
95
+ const std::size_t n = wavelength_nm.size();
96
+ if (zem_m.size() != n || airmass.size() != n || od_tel.size() != n ||
97
+ od_focus.size() != n) {
98
+ throw std::invalid_argument("arrays must have the same length");
99
+ }
100
+ std::vector<double> out(n);
101
+ for (std::size_t i = 0; i < n; ++i) {
102
+ out[i] = self.transmission2<double>(wavelength_nm.data()[i], zem_m.data()[i],
103
+ airmass.data()[i], od_tel.data()[i],
104
+ od_focus.data()[i]);
105
+ }
106
+ return to_numpy(std::move(out));
107
+ },
108
+ nb::arg("wavelength_nm"), nb::arg("zem_m"), nb::arg("airmass"), nb::arg("od_tel"),
109
+ nb::arg("od_focus"))
110
+ .def(
111
+ "telescope_depths",
112
+ [](const TransmissionTable &self, double telescope_altitude_m, double pathlen_m) {
113
+ const auto depths = self.telescope_depths(telescope_altitude_m, pathlen_m);
114
+ return nb::make_tuple(
115
+ to_numpy(std::vector<double>(depths.od_tel.begin(), depths.od_tel.end())),
116
+ to_numpy(std::vector<double>(depths.od_focus.begin(), depths.od_focus.end())));
117
+ },
118
+ nb::arg("telescope_altitude_m"), nb::arg("pathlen_m") = 0.0);
119
+ }
120
+
121
+ // Map a column name of the Python API to its obdeect stream column.
122
+ inline obdeect::stream::Column column_from_name(const std::string &name) {
123
+ for (std::uint32_t c = 0; c < obdeect::stream::n_columns; ++c) {
124
+ const auto column = static_cast<obdeect::stream::Column>(c);
125
+ if (name == obdeect::stream::column_name(column)) {
126
+ return column;
127
+ }
128
+ }
129
+ throw std::invalid_argument("unknown photon column: " + name);
130
+ }
131
+
132
+ // Input columns given as equal-length float32 arrays, keyed by column name. The arrays
133
+ // are not copied; mask is the set of columns given (also when there are no rows).
134
+ struct InputColumns {
135
+ std::vector<std::pair<obdeect::stream::Column, FloatArray>> arrays;
136
+ std::size_t rows{};
137
+ std::uint32_t mask{};
138
+ };
139
+
140
+ inline InputColumns input_columns(const nb::dict &columns) {
141
+ InputColumns input;
142
+ for (auto item : columns) {
143
+ const auto column = column_from_name(nb::cast<std::string>(item.first));
144
+ const auto array = nb::cast<FloatArray>(item.second);
145
+ if (!input.arrays.empty() && array.size() != input.rows) {
146
+ throw std::invalid_argument("photon columns differ in length");
147
+ }
148
+ input.rows = array.size();
149
+ input.mask |= obdeect::stream::column_bit(column);
150
+ input.arrays.emplace_back(column, array);
151
+ }
152
+ return input;
153
+ }
154
+
155
+ // Chunk of rows [offset, offset + count) of the input; first_index is the index of row
156
+ // offset among all photons. count must be at most obdeect::stream::max_chunk_rows.
157
+ inline obdeect::stream::PhotonChunk slice_chunk(const InputColumns &input, std::size_t offset,
158
+ std::size_t count, std::uint64_t first_index) {
159
+ obdeect::stream::PhotonChunk chunk;
160
+ chunk.first_index = first_index;
161
+ chunk.resize(count, input.mask);
162
+ for (const auto &[column, array] : input.arrays) {
163
+ const float *begin = array.data() + offset;
164
+ std::copy(begin, begin + count, chunk[column].begin());
165
+ }
166
+ return chunk;
167
+ }
168
+
169
+ inline void bind_apply(nb::module_ &module) {
170
+ module.def(
171
+ "apply_extinction",
172
+ [](const nb::dict &columns, const obdeect::propagation::TransmissionTable &table,
173
+ double telescope_altitude_m, const DoubleArray &envelope_wavelength,
174
+ const DoubleArray &envelope_value, std::uint64_t seed, std::uint32_t event,
175
+ std::uint32_t telescope, std::uint64_t first_index, bool weighted,
176
+ std::tuple<double, double> wavelength_range, double pathlen_m) {
177
+ const obdeect::propagation::Envelope envelope(to_vector(envelope_wavelength),
178
+ to_vector(envelope_value));
179
+ const auto depths = table.telescope_depths(telescope_altitude_m, pathlen_m);
180
+ obdeect::propagation::ExtinctionSettings settings;
181
+ settings.table = &table;
182
+ settings.depths = &depths;
183
+ settings.envelope = &envelope;
184
+ settings.wavelengths = {std::get<0>(wavelength_range), std::get<1>(wavelength_range)};
185
+ settings.seed = seed;
186
+ settings.event = event;
187
+ settings.telescope = telescope;
188
+ settings.weighted = weighted;
189
+
190
+ // The input is processed in slices of at most 2^16 rows (the PhotonChunk limit). Each
191
+ // slice keeps its photon index (first_index + offset), so the random draws do not
192
+ // depend on slicing; the output index continues across slices.
193
+ const InputColumns input = input_columns(columns);
194
+ // Output columns: the input columns plus the wavelength and p columns, which the
195
+ // pipeline always produces, also when no photon survives.
196
+ const std::uint32_t output_mask =
197
+ input.mask | obdeect::stream::column_bit(obdeect::stream::Column::wavelength) |
198
+ obdeect::stream::column_bit(obdeect::stream::Column::p);
199
+ std::vector<std::vector<float>> out(obdeect::stream::n_columns);
200
+ std::uint64_t out_index = 0;
201
+ for (std::size_t offset = 0; offset < input.rows;
202
+ offset += obdeect::stream::max_chunk_rows) {
203
+ const std::size_t rows =
204
+ std::min<std::size_t>(obdeect::stream::max_chunk_rows, input.rows - offset);
205
+ obdeect::stream::PhotonChunk slice =
206
+ slice_chunk(input, offset, rows, first_index + offset);
207
+ for (auto &chunk : obdeect::propagation::apply_extinction_chunk(std::move(slice),
208
+ settings, out_index)) {
209
+ for (std::uint32_t c = 0; c < obdeect::stream::n_columns; ++c) {
210
+ if (chunk.has(static_cast<obdeect::stream::Column>(c))) {
211
+ out[c].insert(out[c].end(), chunk.columns[c].begin(), chunk.columns[c].end());
212
+ }
213
+ }
214
+ out_index += chunk.rows();
215
+ }
216
+ }
217
+ nb::dict result;
218
+ for (std::uint32_t c = 0; c < obdeect::stream::n_columns; ++c) {
219
+ const auto column = static_cast<obdeect::stream::Column>(c);
220
+ if ((output_mask >> c) & 1U) {
221
+ result[obdeect::stream::column_name(column)] = nb::cast(to_numpy(std::move(out[c])));
222
+ }
223
+ }
224
+ return result;
225
+ },
226
+ nb::arg("columns"), nb::arg("table"), nb::arg("telescope_altitude_m"),
227
+ nb::arg("envelope_wavelength"), nb::arg("envelope_value"), nb::arg("seed"), nb::arg("event"),
228
+ nb::arg("telescope"), nb::arg("first_index"), nb::arg("weighted"),
229
+ nb::arg("wavelength_range"), nb::arg("pathlen_m") = 0.0);
230
+ }
231
+
232
+ } // namespace obdeect_propagation_bindings
233
+
234
+ // Register TransmissionTable and apply_extinction on the extension module.
235
+ inline void bind_extinction(nanobind::module_ &module) {
236
+ obdeect_propagation_bindings::bind_table(module);
237
+ obdeect_propagation_bindings::bind_apply(module);
238
+ }
@@ -0,0 +1,126 @@
1
+ #pragma once
2
+
3
+ // Python API for the atmospheric profile, refraction tables and timing. Included by core.cpp
4
+ // after the nanobind headers. Arrays are one-dimensional float64 and are copied per call.
5
+
6
+ #include <nanobind/nanobind.h>
7
+ #include <nanobind/ndarray.h>
8
+ #include <nanobind/stl/string.h>
9
+
10
+ #include "extinction_bindings.hpp" // to_numpy
11
+ #include "obdeect/propagation/profile.hpp"
12
+ #include "obdeect/propagation/refraction.hpp"
13
+ #include "obdeect/propagation/timing.hpp"
14
+
15
+ #include <cstddef>
16
+ #include <cstdint>
17
+ #include <stdexcept>
18
+ #include <string>
19
+ #include <utility>
20
+ #include <vector>
21
+
22
+ namespace nb = nanobind;
23
+
24
+ namespace obdeect_propagation_bindings {
25
+
26
+ using ConstArray = nb::ndarray<const double, nb::ndim<1>, nb::c_contig, nb::device::cpu>;
27
+
28
+ inline std::vector<double> copy_values(const ConstArray &values) {
29
+ std::vector<double> copy(values.shape(0));
30
+ for (std::size_t i = 0; i < copy.size(); ++i)
31
+ copy[i] = values(i);
32
+ return copy;
33
+ }
34
+
35
+ template <class Function>
36
+ nb::ndarray<nb::numpy, double, nb::ndim<1>> map_values(const ConstArray &heights,
37
+ Function function) {
38
+ std::vector<double> out(heights.shape(0));
39
+ for (std::size_t i = 0; i < out.size(); ++i)
40
+ out[i] = function(heights(i));
41
+ return to_numpy(std::move(out));
42
+ }
43
+
44
+ } // namespace obdeect_propagation_bindings
45
+
46
+ inline void bind_profile(nb::module_ &module) {
47
+ using obdeect::propagation::AtmosphereProfile;
48
+ using obdeect::propagation::RefractionTables;
49
+ using obdeect_propagation_bindings::ConstArray;
50
+ using obdeect_propagation_bindings::copy_values;
51
+ using obdeect_propagation_bindings::map_values;
52
+ using obdeect_propagation_bindings::to_numpy;
53
+
54
+ nb::class_<AtmosphereProfile>(module, "AtmosphereProfile")
55
+ .def(nb::new_([](const std::string &path) { return AtmosphereProfile::from_file(path); }),
56
+ nb::arg("path"),
57
+ "Read an atmprof table (altitude km, density, "
58
+ "thickness, n-1).")
59
+ .def_prop_ro("levels", &AtmosphereProfile::levels)
60
+ .def_prop_ro("bottom_m", &AtmosphereProfile::bottom_m)
61
+ .def_prop_ro("top_m", &AtmosphereProfile::top_m)
62
+ .def(
63
+ "density",
64
+ [](const AtmosphereProfile &p, const ConstArray &h) {
65
+ return map_values(h, [&p](double x) { return p.density(x); });
66
+ },
67
+ nb::arg("height_m"), "Density [g/cm^3] at heights [m a.s.l.] (spline table lookup).")
68
+ .def(
69
+ "thickness",
70
+ [](const AtmosphereProfile &p, const ConstArray &h) {
71
+ return map_values(h, [&p](double x) { return p.thickness(x); });
72
+ },
73
+ nb::arg("height_m"), "Thickness [g/cm^2] at heights [m a.s.l.] (spline table lookup).")
74
+ .def(
75
+ "refractive_index_minus_one",
76
+ [](const AtmosphereProfile &p, const ConstArray &h) {
77
+ return map_values(h, [&p](double x) { return p.refractive_index_minus_one(x); });
78
+ },
79
+ nb::arg("height_m"), "n-1 at heights [m a.s.l.] (spline table lookup).")
80
+ .def(
81
+ "group_index",
82
+ [](const AtmosphereProfile &p, double observation_level_m) {
83
+ return obdeect::propagation::ambient_group_index(p, observation_level_m);
84
+ },
85
+ nb::arg("observation_level_m"), "Refractive index n at the observation level.");
86
+
87
+ nb::class_<RefractionTables>(module, "RefractionTables")
88
+ .def(nb::init<AtmosphereProfile, double>(), nb::arg("profile"),
89
+ nb::arg("observation_level_m"),
90
+ "Refraction tables for a profile and observation level [m a.s.l.].")
91
+ .def_prop_ro("etadsn", &RefractionTables::etadsn)
92
+ .def_prop_ro("observation_level_m",
93
+ [](const RefractionTables &t) { return t.observation_level_cm() / 100.; });
94
+
95
+ module.def(
96
+ "refract",
97
+ [](const RefractionTables &tables, const ConstArray &h, const ConstArray &cx,
98
+ const ConstArray &cy, const ConstArray &x, const ConstArray &y, const ConstArray &t) {
99
+ const std::size_t n = h.shape(0);
100
+ if (cx.shape(0) != n || cy.shape(0) != n || x.shape(0) != n || y.shape(0) != n ||
101
+ t.shape(0) != n)
102
+ throw std::invalid_argument("refract: all arrays must have the same length");
103
+ std::vector<double> ux = copy_values(cx), uy = copy_values(cy), px = copy_values(x),
104
+ py = copy_values(y), pt = copy_values(t);
105
+ std::vector<bool> bent(n);
106
+ for (std::size_t i = 0; i < n; ++i) {
107
+ const double height = h(i);
108
+ bent[i] = obdeect::propagation::bend(tables, height, ux[i], uy[i], px[i], py[i], pt[i]);
109
+ }
110
+ std::vector<std::uint8_t> flags(bent.begin(), bent.end());
111
+ return nb::make_tuple(to_numpy(std::move(ux)), to_numpy(std::move(uy)),
112
+ to_numpy(std::move(px)), to_numpy(std::move(py)),
113
+ to_numpy(std::move(pt)), to_numpy(std::move(flags)));
114
+ },
115
+ nb::arg("tables"), nb::arg("height_m"), nb::arg("cx"), nb::arg("cy"), nb::arg("x_m"),
116
+ nb::arg("y_m"), nb::arg("t_ns"),
117
+ "Bend photons emitted at height_m with direction (cx, cy) and position (x_m, y_m) at the "
118
+ "observation level; returns (cx, cy, x_m, y_m, t_ns, bent) as numpy arrays, bent as uint8.");
119
+
120
+ module.def("straight_path_air_delay_ns", &obdeect::propagation::straight_path_air_delay_ns,
121
+ nb::arg("profile"), nb::arg("height_low_m"), nb::arg("height_high_m"),
122
+ nb::arg("zenith_rad"),
123
+ "Extra light travel time over a straight path between two heights [ns].");
124
+ module.def("propagation_time_ns", &obdeect::propagation::propagation_time_ns, nb::arg("path_m"),
125
+ nb::arg("group_index"), "Light travel time over a path in a medium [ns].");
126
+ }
@@ -0,0 +1,111 @@
1
+ #pragma once
2
+
3
+ // Python API of the atmosphere stage: obdeect.propagation.propagate on stream files (the stream I/O
4
+ // is the C++ core's). Included by core.cpp after the nanobind headers.
5
+
6
+ #include <nanobind/nanobind.h>
7
+ #include <nanobind/stl/map.h>
8
+ #include <nanobind/stl/optional.h>
9
+ #include <nanobind/stl/pair.h>
10
+ #include <nanobind/stl/string.h>
11
+ #include <nanobind/stl/tuple.h>
12
+ #include <nanobind/stl/vector.h>
13
+
14
+ #include "obdeect/propagation/propagator.hpp"
15
+
16
+ #include <cstdint>
17
+ #include <fstream>
18
+ #include <map>
19
+ #include <optional>
20
+ #include <stdexcept>
21
+ #include <string>
22
+ #include <tuple>
23
+ #include <utility>
24
+ #include <vector>
25
+
26
+ namespace nb = nanobind;
27
+
28
+ inline void bind_propagator(nb::module_ &module) {
29
+ module.def(
30
+ "propagate",
31
+ [](const std::string &input_path, const std::string &output_path,
32
+ const std::optional<std::string> &transmission_path, double site_altitude_m,
33
+ const std::optional<std::string> &profile_path,
34
+ const std::vector<std::pair<std::string, std::string>> &envelope_paths,
35
+ const std::optional<std::tuple<double, double>> &wavelength_range, std::uint64_t seed,
36
+ bool weighted, bool store_p, const std::optional<std::string> &scattering_path,
37
+ std::uint32_t scattering_points, double pathlen_m,
38
+ const std::map<std::int64_t, double> &telescope_pathlen_m, bool geometry_only) {
39
+ obdeect::propagation::PropagatorConfig config;
40
+ config.geometry_only = geometry_only;
41
+ config.default_pathlen_m = pathlen_m;
42
+ for (const auto &[id, metres] : telescope_pathlen_m) {
43
+ if (id < 1 || id > static_cast<std::int64_t>(obdeect::stream::max_telescope)) {
44
+ throw std::invalid_argument("telescope_pathlen_m: telescope ids are 1-based");
45
+ }
46
+ config.telescope_pathlen_m[static_cast<std::uint32_t>(id)] = metres;
47
+ }
48
+ if (transmission_path) {
49
+ config.transmission.emplace(obdeect::propagation::TransmissionTable::from_file(
50
+ *transmission_path, site_altitude_m));
51
+ config.transmission_source = *transmission_path;
52
+ }
53
+ config.site_altitude_m = site_altitude_m;
54
+ if (profile_path) {
55
+ config.profile.emplace(obdeect::propagation::AtmosphereProfile::from_file(*profile_path));
56
+ config.profile_source = *profile_path;
57
+ }
58
+ for (const auto &[name, path] : envelope_paths) {
59
+ config.envelope.push_back(obdeect::propagation::read_envelope_file(name, path));
60
+ }
61
+ if (wavelength_range) {
62
+ config.wavelength_range = obdeect::propagation::WavelengthRange{
63
+ std::get<0>(*wavelength_range), std::get<1>(*wavelength_range)};
64
+ }
65
+ config.seed = seed;
66
+ config.mode = weighted ? obdeect::propagation::OutputMode::weighted
67
+ : obdeect::propagation::OutputMode::thinned;
68
+ config.store_p = store_p;
69
+ if (scattering_path) {
70
+ config.scattering.enabled = true;
71
+ config.scattering.points_per_photon = scattering_points;
72
+ config.scattering_table.emplace(
73
+ obdeect::propagation::ScatteringTable::from_file(*scattering_path));
74
+ config.scattering_source = *scattering_path;
75
+ }
76
+
77
+ if (obdeect::propagation::same_file(input_path, output_path)) {
78
+ throw std::invalid_argument("input and output are the same file: " + input_path);
79
+ }
80
+ std::ifstream in(input_path, std::ios::binary);
81
+ if (!in) {
82
+ throw std::runtime_error("cannot open input stream: " + input_path);
83
+ }
84
+ std::ofstream out(output_path, std::ios::binary);
85
+ if (!out) {
86
+ throw std::runtime_error("cannot open output stream: " + output_path);
87
+ }
88
+ const obdeect::propagation::PropagationSummary summary =
89
+ obdeect::propagation::propagate_stream(in, out, std::move(config));
90
+ out.close();
91
+ nb::dict result;
92
+ result["photons_in"] = summary.photons_in;
93
+ result["photons_out"] = summary.photons_out;
94
+ result["dropped"] = summary.dropped;
95
+ result["weight_out"] = summary.weight_out;
96
+ return result;
97
+ },
98
+ nb::arg("input_path"), nb::arg("output_path"), nb::arg("transmission_path"),
99
+ nb::arg("site_altitude_m"), nb::arg("profile_path") = nb::none(),
100
+ nb::arg("envelope_paths") = std::vector<std::pair<std::string, std::string>>{},
101
+ nb::arg("wavelength_range") = nb::none(), nb::arg("seed"), nb::arg("weighted") = false,
102
+ nb::arg("store_p") = false, nb::arg("scattering_path") = nb::none(),
103
+ nb::arg("scattering_points") = std::uint32_t{8}, nb::arg("pathlen_m") = 0.0,
104
+ nb::arg("telescope_pathlen_m") = std::map<std::int64_t, double>{},
105
+ nb::arg("geometry_only") = false,
106
+ "Run the atmosphere stage on a photon stream file (geometry_only: no extinction, see "
107
+ "docs/propagation.md). envelope_paths: (name, file) pairs of "
108
+ "two-column text tables (nm, value). scattering_path: optional scattering table (single "
109
+ "scattering of the primaries, off when None). pathlen_m: path inside each telescope [m]; "
110
+ "telescope_pathlen_m: {telescope_id (1-based): metres} overrides.");
111
+ }