obdeect 0.6.0__tar.gz → 0.7.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 (102) hide show
  1. {obdeect-0.6.0 → obdeect-0.7.0}/.github/workflows/ci.yml +1 -3
  2. {obdeect-0.6.0 → obdeect-0.7.0}/.github/workflows/pypi.yml +1 -1
  3. {obdeect-0.6.0 → obdeect-0.7.0}/.github/workflows/tests.yml +1 -1
  4. {obdeect-0.6.0 → obdeect-0.7.0}/AGENTS.md +39 -8
  5. {obdeect-0.6.0 → obdeect-0.7.0}/PKG-INFO +6 -4
  6. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/bindings/core.cpp +17 -9
  7. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/bunches.hpp +87 -25
  8. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/model.hpp +3 -0
  9. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/model_json.hpp +8 -1
  10. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/tests/test_model_json.cpp +83 -0
  11. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/tests/test_trace.cpp +64 -1
  12. {obdeect-0.6.0 → obdeect-0.7.0}/docs/VALIDATION.md +41 -0
  13. obdeect-0.7.0/docs/air-shower-photons.md +103 -0
  14. {obdeect-0.6.0 → obdeect-0.7.0}/docs/batch-tracing.md +48 -6
  15. obdeect-0.7.0/docs/changes/27.feature.md +1 -0
  16. obdeect-0.7.0/docs/changes/28.maintenance.md +1 -0
  17. {obdeect-0.6.0 → obdeect-0.7.0}/docs/installation.md +5 -0
  18. {obdeect-0.6.0 → obdeect-0.7.0}/docs/optical-models.md +19 -1
  19. {obdeect-0.6.0 → obdeect-0.7.0}/docs/usage.md +2 -1
  20. {obdeect-0.6.0 → obdeect-0.7.0}/docs/user-guide.md +1 -0
  21. {obdeect-0.6.0 → obdeect-0.7.0}/pyproject.toml +4 -3
  22. {obdeect-0.6.0 → obdeect-0.7.0}/python/obdeect/cli.py +6 -1
  23. {obdeect-0.6.0 → obdeect-0.7.0}/python/obdeect/compiler.py +38 -0
  24. {obdeect-0.6.0 → obdeect-0.7.0}/python/obdeect/model.py +150 -10
  25. obdeect-0.7.0/python/tests/test_axes_offset.py +199 -0
  26. {obdeect-0.6.0 → obdeect-0.7.0}/python/tests/test_bunches.py +322 -4
  27. {obdeect-0.6.0 → obdeect-0.7.0}/python/tests/test_docs_versions.py +15 -0
  28. {obdeect-0.6.0 → obdeect-0.7.0}/python/tests/test_model.py +16 -2
  29. {obdeect-0.6.0 → obdeect-0.7.0}/.clang-format +0 -0
  30. {obdeect-0.6.0 → obdeect-0.7.0}/.clang-tidy +0 -0
  31. {obdeect-0.6.0 → obdeect-0.7.0}/.editorconfig +0 -0
  32. {obdeect-0.6.0 → obdeect-0.7.0}/.gitattributes +0 -0
  33. {obdeect-0.6.0 → obdeect-0.7.0}/.github/workflows/docs.yml +0 -0
  34. {obdeect-0.6.0 → obdeect-0.7.0}/.gitignore +0 -0
  35. {obdeect-0.6.0 → obdeect-0.7.0}/.pre-commit-config.yaml +0 -0
  36. {obdeect-0.6.0 → obdeect-0.7.0}/CHANGELOG.md +0 -0
  37. {obdeect-0.6.0 → obdeect-0.7.0}/CITATION.cff +0 -0
  38. {obdeect-0.6.0 → obdeect-0.7.0}/CMakeLists.txt +0 -0
  39. {obdeect-0.6.0 → obdeect-0.7.0}/CMakePresets.json +0 -0
  40. {obdeect-0.6.0 → obdeect-0.7.0}/LICENSE +0 -0
  41. {obdeect-0.6.0 → obdeect-0.7.0}/README.md +0 -0
  42. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/bench/bench_trace.cpp +0 -0
  43. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/bench/replay.cpp +0 -0
  44. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/bench/replay_compare.hpp +0 -0
  45. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/bindings/README.md +0 -0
  46. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/bounds.hpp +0 -0
  47. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/json.hpp +0 -0
  48. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/obscurers.hpp +0 -0
  49. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/random.hpp +0 -0
  50. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/source.hpp +0 -0
  51. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/surfaces.hpp +0 -0
  52. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/table.hpp +0 -0
  53. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/trace.hpp +0 -0
  54. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/include/obdeect/vec.hpp +0 -0
  55. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/tests/check.hpp +0 -0
  56. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/tests/data/make_polynomial_reference.py +0 -0
  57. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/tests/legacy_asphere.hpp +0 -0
  58. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/tests/test_asphere_steps.cpp +0 -0
  59. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/tests/test_bounds.cpp +0 -0
  60. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/tests/test_kernels.cpp +0 -0
  61. {obdeect-0.6.0 → obdeect-0.7.0}/cpp/tests/test_replay_compare.cpp +0 -0
  62. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/figures/baffle_area.png +0 -0
  63. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/figures/mst_shadowing.png +0 -0
  64. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/figures/telescope_lst.png +0 -0
  65. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/figures/telescope_mst_flashcam.png +0 -0
  66. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/figures/telescope_mst_nectarcam.png +0 -0
  67. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/figures/telescope_sct.png +0 -0
  68. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/figures/telescope_sst.png +0 -0
  69. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/figures/telescopes-provenance.json +0 -0
  70. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/figures/validation_psf_area.png +0 -0
  71. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/images/obdeect_21ratio_large.jpg +0 -0
  72. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/images/obdeect_21ratio_small.jpg +0 -0
  73. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/images/obdeect_large.jpg +0 -0
  74. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/images/obdeect_logo.jpg +0 -0
  75. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/images/obdeect_small.jpg +0 -0
  76. {obdeect-0.6.0 → obdeect-0.7.0}/docs/_static/obdeect.css +0 -0
  77. {obdeect-0.6.0 → obdeect-0.7.0}/docs/api.md +0 -0
  78. {obdeect-0.6.0 → obdeect-0.7.0}/docs/build_versions.py +0 -0
  79. {obdeect-0.6.0 → obdeect-0.7.0}/docs/changelog.md +0 -0
  80. {obdeect-0.6.0 → obdeect-0.7.0}/docs/changes/.gitkeep +0 -0
  81. {obdeect-0.6.0 → obdeect-0.7.0}/docs/conf.py +0 -0
  82. {obdeect-0.6.0 → obdeect-0.7.0}/docs/figures/make_figures.py +0 -0
  83. {obdeect-0.6.0 → obdeect-0.7.0}/docs/figures/telescopes.yml +0 -0
  84. {obdeect-0.6.0 → obdeect-0.7.0}/docs/index.md +0 -0
  85. {obdeect-0.6.0 → obdeect-0.7.0}/docs/light-sources.md +0 -0
  86. {obdeect-0.6.0 → obdeect-0.7.0}/docs/limitations.md +0 -0
  87. {obdeect-0.6.0 → obdeect-0.7.0}/docs/optical-elements.md +0 -0
  88. {obdeect-0.6.0 → obdeect-0.7.0}/docs/optics.md +0 -0
  89. {obdeect-0.6.0 → obdeect-0.7.0}/docs/python-guide.md +0 -0
  90. {obdeect-0.6.0 → obdeect-0.7.0}/docs/shadowing.md +0 -0
  91. {obdeect-0.6.0 → obdeect-0.7.0}/docs/telescopes.md +0 -0
  92. {obdeect-0.6.0 → obdeect-0.7.0}/python/obdeect/__init__.py +0 -0
  93. {obdeect-0.6.0 → obdeect-0.7.0}/python/obdeect/formats.py +0 -0
  94. {obdeect-0.6.0 → obdeect-0.7.0}/python/obdeect/plot.py +0 -0
  95. {obdeect-0.6.0 → obdeect-0.7.0}/python/obdeect/propagation.py +0 -0
  96. {obdeect-0.6.0 → obdeect-0.7.0}/python/obdeect/psf.py +0 -0
  97. {obdeect-0.6.0 → obdeect-0.7.0}/python/obdeect/repository.py +0 -0
  98. {obdeect-0.6.0 → obdeect-0.7.0}/python/obdeect/simtel_tables.py +0 -0
  99. {obdeect-0.6.0 → obdeect-0.7.0}/python/tests/telescopes.py +0 -0
  100. {obdeect-0.6.0 → obdeect-0.7.0}/python/tests/test_compiler.py +0 -0
  101. {obdeect-0.6.0 → obdeect-0.7.0}/python/tests/test_formats.py +0 -0
  102. {obdeect-0.6.0 → obdeect-0.7.0}/python/tests/test_propagation.py +0 -0
@@ -22,11 +22,9 @@ jobs:
22
22
  with:
23
23
  python-version: '3.14'
24
24
  - name: Install linters
25
- run: python -m pip install --upgrade pip pre-commit ruff clang-format
25
+ run: python -m pip install --upgrade pip pre-commit clang-format
26
26
  - name: Run repository hooks
27
27
  run: pre-commit run --all-files
28
- - name: Check Python formatting and lint
29
- run: ruff format --check python && ruff check python
30
28
  - name: Check C++ formatting
31
29
  run: >-
32
30
  find cpp -type f \( -name '*.cpp' -o -name '*.hpp' \) -print0
@@ -36,7 +36,7 @@ jobs:
36
36
  - name: Build and smoke-test wheels
37
37
  env:
38
38
  CIBW_BUILD: "cp314-*"
39
- CIBW_TEST_REQUIRES: "pytest scipy"
39
+ CIBW_TEST_REQUIRES: "pytest matplotlib"
40
40
  CIBW_TEST_COMMAND: python -m pytest {project}/python/tests -q
41
41
  run: python -m cibuildwheel --output-dir wheelhouse
42
42
  - uses: actions/upload-artifact@v7
@@ -27,7 +27,7 @@ jobs:
27
27
  with:
28
28
  python-version: '3.14'
29
29
  - name: Install Python package and test dependencies
30
- run: python -m pip install --upgrade pip && python -m pip install . pytest scipy
30
+ run: python -m pip install --upgrade pip && python -m pip install ".[plot]" pytest
31
31
  - name: Configure and build
32
32
  run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel
33
33
  - name: Run native and Python tests
@@ -5,8 +5,20 @@
5
5
  `obdeect` is a portable C++20 optical ray tracer with a thin Python interface.
6
6
  It traces photons from a defined entrance surface to a focal/sensor surface.
7
7
  Air-shower generation, electronics, triggers, and event writing are out of
8
- scope. Read the relevant code and tests first; use the workspace plans and
9
- validation matrix for architectural or scientific changes.
8
+ scope. Read the relevant code and tests first; use `docs/VALIDATION.md` and
9
+ `docs/optical-models.md` for architectural or scientific changes.
10
+
11
+ ## Layout
12
+
13
+ - `cpp/include/obdeect/`: C++ core headers (kernels, optical models, public API).
14
+ - `cpp/bindings/core.cpp`: nanobind extension; the only C++ file that may
15
+ depend on Python.
16
+ - `cpp/tests/`: native tests (`test_*.cpp`), shared helpers (`check.hpp`),
17
+ and fixtures in `cpp/tests/data/`.
18
+ - `cpp/bench/`: benchmarks and the replay comparison tool.
19
+ - `python/obdeect/`: Python boundary (compile, trace, formats, plotting, CLI).
20
+ - `python/tests/`: pytest suite; `telescopes.py` holds shared test helpers.
21
+ - `docs/`: Sphinx sources. `docs/changes/` is towncrier input (see below).
10
22
 
11
23
  ## Design rules
12
24
 
@@ -34,7 +46,7 @@ validation matrix for architectural or scientific changes.
34
46
  - CTAO support is an explicit importer/adapter or versioned fixture only. It
35
47
  must not be required to build, test, or run the generic engine, and it must
36
48
  never be an implicit fallback.
37
- - Use python >3.14. Do not fall back to earlier versions.
49
+ - Use Python >=3.14 (as in `pyproject.toml`). Do not fall back to earlier versions.
38
50
 
39
51
  ## Changes and validation
40
52
 
@@ -55,9 +67,11 @@ validation matrix for architectural or scientific changes.
55
67
  ## Build, test, and lint
56
68
 
57
69
  Run from this directory. Configure before static analysis so the compilation
58
- database is current.
70
+ database is current. The `debug` preset enables `OBDEECT_BUILD_TESTING` and
71
+ writes to `build/debug/`; wheels build with `OBDEECT_BUILD_TESTING=OFF`.
59
72
 
60
73
  ```bash
74
+ python -m pip install -e '.[dev]' # editable install; rebuilds the extension on C++ changes
61
75
  cmake --preset debug
62
76
  cmake --build --preset debug
63
77
  ctest --test-dir build/debug --output-on-failure
@@ -77,7 +91,24 @@ the focused tests before the full suite. A clean handoff requires both
77
91
  `ruff format --check python` and `ruff check python`; do not rely on a passing
78
92
  test run alone because stale test references can fail collection or linting.
79
93
 
80
- Run `clang-tidy -p build/debug cpp/src/<changed-file>.cpp` for changed C++
81
- translation units when available, and `pre-commit run --all-files` before
82
- handoff. Start with the smallest relevant test; run the full checks for shared
83
- kernels, interfaces, configuration, or tooling changes.
94
+ Run `clang-tidy -p build/debug <changed-file>` for changed C++ translation units
95
+ (`cpp/tests/`, `cpp/bench/`, `cpp/bindings/`, or `cpp/include/obdeect/` headers)
96
+ when available, and `pre-commit run --all-files` before handoff. Pre-commit runs
97
+ ruff, codespell, markdownlint, yamllint, actionlint, pyproject-fmt, shellcheck,
98
+ and the towncrier check. Start with the smallest relevant test; run the full
99
+ checks for shared kernels, interfaces, configuration, or tooling changes.
100
+
101
+ CI also builds and tests a `-DOBDEECT_USE_FMA=ON` Release configuration and an
102
+ AddressSanitizer/UBSan Debug configuration. Run those locally for changes to
103
+ floating-point kernels or memory-handling code:
104
+
105
+ ```bash
106
+ cmake -S . -B build-sanitize -DCMAKE_BUILD_TYPE=Debug \
107
+ -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer" \
108
+ -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"
109
+ cmake --build build-sanitize --parallel
110
+ ctest --test-dir build-sanitize --output-on-failure
111
+ ```
112
+
113
+ Docs build with warnings as errors: `sphinx-build -W --keep-going -b html docs docs/_build/html`.
114
+ Regenerate figures only from new validation results, via `docs/figures/make_figures.py`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: obdeect
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Ray tracing for imaging atmospheric Cherenkov telescopes
5
5
  Author: obdeect contributors
6
6
  License: BSD 3-Clause License
@@ -39,21 +39,23 @@ Classifier: Programming Language :: C++
39
39
  Classifier: Programming Language :: Python :: 3 :: Only
40
40
  Classifier: Programming Language :: Python :: 3.14
41
41
  Requires-Python: >=3.14
42
- Requires-Dist: matplotlib>=3.8
43
42
  Requires-Dist: numpy>=2.3
44
43
  Provides-Extra: dev
45
44
  Requires-Dist: nanobind>=3.1; extra == "dev"
46
45
  Requires-Dist: pre-commit; extra == "dev"
47
46
  Requires-Dist: pytest; extra == "dev"
48
47
  Requires-Dist: ruff; extra == "dev"
49
- Requires-Dist: scipy; extra == "dev"
50
48
  Provides-Extra: docs
51
- Requires-Dist: astropy; extra == "docs"
52
49
  Requires-Dist: myst-parser; extra == "docs"
53
50
  Requires-Dist: pydata-sphinx-theme; extra == "docs"
54
51
  Requires-Dist: pyyaml; extra == "docs"
55
52
  Requires-Dist: sphinx; extra == "docs"
56
53
  Requires-Dist: sphinx-design; extra == "docs"
54
+ Provides-Extra: figures
55
+ Requires-Dist: astropy; extra == "figures"
56
+ Requires-Dist: matplotlib>=3.8; extra == "figures"
57
+ Provides-Extra: plot
58
+ Requires-Dist: matplotlib>=3.8; extra == "plot"
57
59
  Description-Content-Type: text/markdown
58
60
 
59
61
  # obdeect
@@ -189,14 +189,14 @@ public:
189
189
 
190
190
  // Photon bunches (see obdeect::trace_bunches). Acceptance and density are
191
191
  // given as arrays; the output arrays are views of the rows written.
192
- nb::dict
193
- trace_bunches(In1<float> x, In1<float> y, In1<float> cx, In1<float> cy, In1<float> time_ns,
194
- In1<float> zem, In1<float> size, In1<float> wavelength_nm,
195
- In1<std::uint64_t> bunch_id, In1<double> rotation, In1<double> offset_m,
196
- double length_unit_m, double plane_z, std::uint64_t seed,
197
- const std::optional<std::tuple<In1<double>, In1<double>, In1<double>>> &acceptance,
198
- const std::optional<std::tuple<In1<double>, In1<double>>> &density, bool roulette,
199
- bool classify_losses, obdeect::ThreadPool &pool) const {
192
+ nb::dict trace_bunches(
193
+ In1<float> x, In1<float> y, In1<float> cx, In1<float> cy, In1<float> time_ns, In1<float> zem,
194
+ In1<float> size, In1<float> wavelength_nm, In1<std::uint64_t> bunch_id, In1<double> rotation,
195
+ In1<double> offset_m, double length_unit_m, double plane_z, std::uint64_t seed,
196
+ const std::optional<std::tuple<In1<double>, In1<double>, In1<double>>> &acceptance,
197
+ const std::optional<std::tuple<In1<double>, In1<double>>> &density,
198
+ const std::optional<std::tuple<double, double>> &wavelength_law, bool roulette,
199
+ bool classify_losses, bool normalise_transmission, obdeect::ThreadPool &pool) const {
200
200
  const std::size_t n = x.shape(0);
201
201
  for (const std::size_t length :
202
202
  {y.shape(0), cx.shape(0), cy.shape(0), time_ns.shape(0), zem.shape(0), size.shape(0),
@@ -213,9 +213,11 @@ public:
213
213
 
214
214
  obdeect::Map2D acceptance_map;
215
215
  obdeect::LinearDensity wavelength_density;
216
+ obdeect::InverseSquareLaw law;
216
217
  obdeect::BunchOptions options;
217
218
  options.roulette = roulette;
218
219
  options.classify_losses = classify_losses;
220
+ options.normalise_transmission = normalise_transmission;
219
221
  try {
220
222
  if (acceptance) {
221
223
  const auto &[wavelengths, heights, values] = *acceptance;
@@ -234,6 +236,11 @@ public:
234
236
  {weights.data(), weights.data() + weights.shape(0)});
235
237
  options.wavelength_density = &wavelength_density;
236
238
  }
239
+ if (wavelength_law) {
240
+ const auto &[lo_nm, hi_nm] = *wavelength_law;
241
+ law = {lo_nm, hi_nm};
242
+ options.wavelength_law = &law;
243
+ }
237
244
  } catch (const std::invalid_argument &error) {
238
245
  throw nb::value_error(error.what());
239
246
  }
@@ -501,7 +508,8 @@ NB_MODULE(_core, module) {
501
508
  .def("trace_bunches", &Model::trace_bunches, "x"_a, "y"_a, "cx"_a, "cy"_a, "time_ns"_a,
502
509
  "zem"_a, "size"_a, "wavelength_nm"_a, "bunch_id"_a, "rotation"_a, "offset_m"_a,
503
510
  "length_unit_m"_a, "plane_z"_a, nb::kw_only(), "seed"_a, "acceptance"_a = nb::none(),
504
- "wavelength_density"_a = nb::none(), "roulette"_a = true, "classify_losses"_a = true,
511
+ "wavelength_density"_a = nb::none(), "wavelength_law"_a = nb::none(),
512
+ "roulette"_a = true, "classify_losses"_a = true, "normalise_transmission"_a = false,
505
513
  "pool"_a)
506
514
  .def("obscurer_crossings", &Model::obscurer_crossings, "start"_a, "end"_a, "leg"_a);
507
515
  module.def("trace_many", &trace_many, "items"_a, nb::kw_only(), "seed"_a, "pool"_a);
@@ -33,6 +33,13 @@ struct BunchFrame {
33
33
  double plane_z{}; // Input units.
34
34
  };
35
35
 
36
+ // Wavelength of bunches with wavelength 0: density proportional to 1/lambda^2
37
+ // on [lo_nm, hi_nm], truncated to integer nm.
38
+ struct InverseSquareLaw {
39
+ double lo_nm{};
40
+ double hi_nm{};
41
+ };
42
+
36
43
  struct BunchOptions {
37
44
  // Acceptance outside the telescope as a function of wavelength [nm] (x) and
38
45
  // emission height [m] (y), values in [0, 1]; null means 1.
@@ -40,6 +47,10 @@ struct BunchOptions {
40
47
  const LinearDensity *wavelength_density{}; // Over nm, for bunches of wavelength 0.
41
48
  bool roulette{true}; // Survivors get weight 1 (else the weight ratio).
42
49
  bool classify_losses{true};
50
+ // Roulette weights and bound relative to the telescope transmission's on-axis value.
51
+ bool normalise_transmission{false};
52
+ // Alternative to wavelength_density for bunches of wavelength 0 (not both).
53
+ const InverseSquareLaw *wavelength_law{};
43
54
  };
44
55
 
45
56
  // Output columns, with room for the rows the allocator was asked for. Vectors
@@ -102,8 +113,9 @@ public:
102
113
  const double size = in_.size[i];
103
114
  const double lambda = in_.wavelength_nm[i];
104
115
  if (!(size > 0.0 && size <= kMaxBunchSize) || !std::isfinite(lambda) || lambda < 0.0 ||
105
- (lambda == 0.0 && !options_.wavelength_density) || !std::isfinite(in_.x[i]) ||
106
- !std::isfinite(in_.y[i]) || !std::isfinite(in_.cx[i]) || !std::isfinite(in_.cy[i]) ||
116
+ (lambda == 0.0 && !options_.wavelength_density && !options_.wavelength_law) ||
117
+ !std::isfinite(in_.x[i]) || !std::isfinite(in_.y[i]) || !std::isfinite(in_.cx[i]) ||
118
+ !std::isfinite(in_.cy[i]) ||
107
119
  !(static_cast<double>(in_.cx[i]) * in_.cx[i] +
108
120
  static_cast<double>(in_.cy[i]) * in_.cy[i] <=
109
121
  1.0 + 1e-6) ||
@@ -117,30 +129,40 @@ public:
117
129
 
118
130
  // Trace the bunches [begin, end), writing rows from `row` on; returns the
119
131
  // number of rows written.
120
- std::size_t run(std::size_t begin, std::size_t end, const BunchResult &out,
121
- std::size_t row) const {
132
+ std::size_t run(std::size_t begin, std::size_t end, const BunchResult &out, std::size_t row,
133
+ std::uint8_t *keep_flags, std::vector<double> &roulette_draws,
134
+ bool thinning) const {
122
135
  const std::size_t first = row;
123
136
  const double u = frame_.length_unit_m;
124
137
  const double *r = frame_.rotation;
125
138
  // Position and direction in the telescope frame, computed for the first
126
139
  // photon of a bunch that survives the thinning.
140
+ // The model's axes offset O displaces the optical origin: P is traced at R P - O.
141
+ const auto &axes = model_.axes_offset_m;
127
142
  const auto transform = [&](std::size_t i) {
128
143
  const double x = u * in_.x[i], y = u * in_.y[i], z = u * frame_.plane_z;
129
144
  const double cx = in_.cx[i], cy = in_.cy[i];
130
145
  const double cz = -std::sqrt(std::max(0.0, 1.0 - cx * cx - cy * cy));
131
- return Ray{{r[0] * x + r[1] * y + r[2] * z + frame_.offset_m[0],
132
- r[3] * x + r[4] * y + r[5] * z + frame_.offset_m[1],
133
- r[6] * x + r[7] * y + r[8] * z + frame_.offset_m[2]},
146
+ return Ray{{r[0] * x + r[1] * y + r[2] * z + frame_.offset_m[0] - axes[0],
147
+ r[3] * x + r[4] * y + r[5] * z + frame_.offset_m[1] - axes[1],
148
+ r[6] * x + r[7] * y + r[8] * z + frame_.offset_m[2] - axes[2]},
134
149
  {r[0] * cx + r[1] * cy + r[2] * cz, r[3] * cx + r[4] * cy + r[5] * cz,
135
150
  r[6] * cx + r[7] * cy + r[8] * cz}};
136
151
  };
137
- // Bound and acceptance of the last wavelength (and height): showers have
138
- // many bunches of one wavelength.
152
+ // Divisor of the bound and of the roulette weight: the on-axis transmission,
153
+ // if normalised. The telescope transmission enters the photon weights as
154
+ // t(theta) / t0 because sim_telarray already applies t0 in its bunch loop.
155
+ const double norm = options_.normalise_transmission && model_.telescope_transmission &&
156
+ model_.telescope_transmission->on_axis > 0.0
157
+ ? model_.telescope_transmission->on_axis
158
+ : 1.0;
159
+ // Bound (relative to norm) and acceptance of the last wavelength (and
160
+ // height): showers have many bunches of one wavelength.
139
161
  double cached_lambda = -1.0, cached_zem = 0.0, cached_bound = 0.0, cached_acceptance = 1.0;
140
162
  const auto response = [&](double lambda, double zem_m) {
141
163
  if (lambda != cached_lambda) {
142
164
  cached_lambda = lambda;
143
- cached_bound = response_bound(model_, lambda);
165
+ cached_bound = response_bound(model_, lambda) / norm;
144
166
  cached_zem = std::numeric_limits<double>::quiet_NaN();
145
167
  }
146
168
  if (zem_m != cached_zem) {
@@ -148,6 +170,7 @@ public:
148
170
  cached_acceptance = options_.acceptance ? options_.acceptance->at(lambda, zem_m) : 1.0;
149
171
  }
150
172
  };
173
+ std::size_t candidate = 0, survivor = 0;
151
174
  for (std::size_t i = begin; i < end; ++i) {
152
175
  const double zem_m = u * in_.zem[i];
153
176
  const double size = in_.size[i], time = in_.time_ns[i];
@@ -161,17 +184,36 @@ public:
161
184
  Ray ray;
162
185
  for (std::size_t k = 0; k < n_sub; ++k) {
163
186
  const auto sub = static_cast<std::uint32_t>(k);
187
+ const std::size_t flag = candidate++;
188
+ if (!thinning && !keep_flags[flag])
189
+ continue;
164
190
  const double frac = static_cast<double>(k + 1) <= size ? 1.0 : size - std::floor(size);
165
191
  if (!fixed) {
166
- lambda =
167
- options_.wavelength_density->sample(uniform4(seed_, id, Stream::wavelength, sub)[0]);
192
+ if (options_.wavelength_law) {
193
+ // Inverse transform of 1/lambda^2; u in (0, 1), so the value lies in (lo, hi).
194
+ const auto &law = *options_.wavelength_law;
195
+ const double u = uniform4(seed_, id, Stream::wavelength, sub)[0];
196
+ const double exact = 1.0 / (1.0 / law.lo_nm - u * (1.0 / law.lo_nm - 1.0 / law.hi_nm));
197
+ // Truncation to integer nm as sim_telarray does: lambda in [floor(lo), hi).
198
+ lambda = std::floor(exact);
199
+ } else {
200
+ lambda = options_.wavelength_density->sample(
201
+ uniform4(seed_, id, Stream::wavelength, sub)[0]);
202
+ }
168
203
  response(lambda, zem_m);
169
204
  }
170
205
  const double bound = cached_bound;
171
206
  const double keep = frac * cached_acceptance * bound;
172
- const auto draw = uniform4(seed_, id, Stream::thinning, sub);
173
- if (!(draw[0] < keep))
207
+ if (thinning) {
208
+ const auto draw = uniform4(seed_, id, Stream::thinning, sub);
209
+ keep_flags[flag] = draw[0] < keep;
210
+ if (keep_flags[flag]) {
211
+ roulette_draws.push_back(draw[1]);
212
+ ++row;
213
+ }
174
214
  continue;
215
+ }
216
+ const double roulette_draw = roulette_draws[survivor++];
175
217
  if (!placed) {
176
218
  ray = transform(i);
177
219
  placed = true;
@@ -180,8 +222,8 @@ public:
180
222
  options_.classify_losses, sub);
181
223
  if (t.status != Status::detected)
182
224
  continue;
183
- const double ratio = std::min(1.0, t.weight / bound);
184
- if (options_.roulette && !(draw[1] < ratio))
225
+ const double ratio = std::min(1.0, (t.weight / norm) / cached_bound);
226
+ if (options_.roulette && !(roulette_draw < ratio))
185
227
  continue;
186
228
  out.position_m[3 * row] = t.position.x;
187
229
  out.position_m[3 * row + 1] = t.position.y;
@@ -219,12 +261,12 @@ private:
219
261
  // unique per seed.
220
262
  //
221
263
  // Invalid input (a non-orthonormal frame, acceptance outside [0, 1], a bunch of
222
- // wavelength 0 without a density, bad sizes) throws std::invalid_argument
264
+ // wavelength 0 without a density or law, bad sizes) throws std::invalid_argument
223
265
  // before any tracing; so does a transmission without a known maximum
224
266
  // (ModelError).
225
267
  //
226
- // `allocate(rows)` is called once, after validation, with the largest number
227
- // of rows the call can write, and returns the output columns for that many
268
+ // `allocate(rows)` is called once, after validation, with the number
269
+ // of thinning survivors, and returns the output columns for that many
228
270
  // rows. Returns the rows written; they come first, in input order, bunch by
229
271
  // bunch and sub-photon by sub-photon.
230
272
  template <class Allocate>
@@ -236,6 +278,15 @@ std::size_t trace_bunches(const OpticalModel &model, const BunchBlock &in, const
236
278
  detail::validate_acceptance(*options.acceptance);
237
279
  if (options.wavelength_density && !(options.wavelength_density->lower() > 0.0))
238
280
  throw std::invalid_argument("bunch wavelength density: nodes must start above 0 nm");
281
+ if (options.wavelength_law) {
282
+ const auto &law = *options.wavelength_law;
283
+ // lo_nm >= 1 keeps floor(lambda) >= 1 for every sample, so no photon is at wavelength 0.
284
+ if (!std::isfinite(law.lo_nm) || !std::isfinite(law.hi_nm) || !(law.lo_nm >= 1.0) ||
285
+ !(law.hi_nm > law.lo_nm))
286
+ throw std::invalid_argument("bunch wavelength law: need finite 1 <= lo_nm < hi_nm");
287
+ if (options.wavelength_density)
288
+ throw std::invalid_argument("bunch wavelength: set one wavelength law");
289
+ }
239
290
  validate_bound_support(model); // Throws ModelError for an unsupported transmission.
240
291
  const detail::BunchTask task(model, in, frame, options, seed);
241
292
 
@@ -250,15 +301,26 @@ std::size_t trace_bunches(const OpticalModel &model, const BunchBlock &in, const
250
301
  });
251
302
  if (std::find(bad.begin(), bad.end(), char{1}) != bad.end())
252
303
  throw std::invalid_argument("bunches: invalid size, position, direction, time or wavelength "
253
- "(wavelength 0 needs a wavelength density)");
304
+ "(wavelength 0 needs a wavelength density or law)");
254
305
  for (std::size_t c = 0; c < chunks; ++c)
255
306
  start[c + 1] += start[c];
256
307
 
257
- const BunchResult out = allocate(start.back());
308
+ // Record thinning once, including the roulette draw needed after tracing.
309
+ std::vector<std::uint8_t> keep_flags(start.back());
310
+ std::vector<std::vector<double>> roulette_draws(chunks);
311
+ pool.run(chunks, [&](std::size_t c) {
312
+ found[c] = task.run(c * kChunkSize, std::min(in.n, (c + 1) * kChunkSize), BunchResult{}, 0,
313
+ keep_flags.data() + start[c], roulette_draws[c], true);
314
+ });
315
+ std::vector<std::size_t> output_start(chunks + 1, 0);
316
+ for (std::size_t c = 0; c < chunks; ++c)
317
+ output_start[c + 1] = output_start[c] + found[c];
318
+ const BunchResult out = allocate(output_start.back());
258
319
 
259
- // Second pass: chunk c writes from start[c] on.
320
+ // Trace survivors into their chunk's allocated range.
260
321
  pool.run(chunks, [&](std::size_t c) {
261
- found[c] = task.run(c * kChunkSize, std::min(in.n, (c + 1) * kChunkSize), out, start[c]);
322
+ found[c] = task.run(c * kChunkSize, std::min(in.n, (c + 1) * kChunkSize), out, output_start[c],
323
+ keep_flags.data() + start[c], roulette_draws[c], false);
262
324
  });
263
325
 
264
326
  std::vector<detail::Column> columns;
@@ -269,8 +331,8 @@ std::size_t trace_bunches(const OpticalModel &model, const BunchBlock &in, const
269
331
  detail::add_column(columns, out.wavelength_nm, 1);
270
332
  detail::add_column(columns, out.bunch_index, 1);
271
333
  detail::add_column(columns, out.sub_photon, 1);
272
- start.pop_back();
273
- return detail::compact_chunks(columns, start, found);
334
+ output_start.pop_back();
335
+ return detail::compact_chunks(columns, output_start, found);
274
336
  }
275
337
 
276
338
  } // namespace obdeect
@@ -4,6 +4,7 @@
4
4
  #include "obdeect/surfaces.hpp"
5
5
  #include "obdeect/table.hpp"
6
6
 
7
+ #include <array>
7
8
  #include <cstdint>
8
9
  #include <optional>
9
10
  #include <stdexcept>
@@ -72,6 +73,8 @@ struct OpticalModel {
72
73
  Obscurers obscurers;
73
74
  std::optional<TelescopeTransmission> telescope_transmission;
74
75
  double propagation_group_index{1.0};
76
+ // Optical origin offset O [m, telescope frame]: a ground point P is traced at R P - O.
77
+ std::array<double, 3> axes_offset_m{};
75
78
  };
76
79
 
77
80
  } // namespace obdeect
@@ -439,8 +439,15 @@ inline Obscurer obscurer(const Node &node, std::vector<Plane> &planes) {
439
439
  root["format"].fail("expected '" + std::string(kModelFormat) + "'");
440
440
  const Node optics = root["optics"];
441
441
  (void)optics.object({"propagation_group_index", "telescope_transmission", "primary", "secondary",
442
- "secondary_blocks_incoming", "focal_surface", "obscurers"});
442
+ "secondary_blocks_incoming", "focal_surface", "obscurers", "axes_offset_m"});
443
443
  OpticalModel model;
444
+ if (optics.has("axes_offset_m")) {
445
+ const auto offset = optics["axes_offset_m"].array();
446
+ if (offset.size() != 3)
447
+ optics["axes_offset_m"].fail("expected three components");
448
+ for (std::size_t i = 0; i < 3; ++i)
449
+ model.axes_offset_m[i] = offset[i].number();
450
+ }
444
451
  std::vector<std::uint32_t> ids;
445
452
  model.propagation_group_index = optics["propagation_group_index"].number();
446
453
  if (!(model.propagation_group_index >= 1.0))
@@ -1,9 +1,16 @@
1
1
  // Optical-model loading: valid documents load; every error names its path.
2
2
  #include "check.hpp"
3
3
 
4
+ #include "obdeect/bunches.hpp"
4
5
  #include "obdeect/model_json.hpp"
5
6
 
7
+ #include <algorithm>
8
+ #include <array>
9
+ #include <cmath>
10
+ #include <cstddef>
11
+ #include <cstdint>
6
12
  #include <string>
13
+ #include <vector>
7
14
 
8
15
  using namespace obdeect;
9
16
 
@@ -63,6 +70,79 @@ bool contains(const std::string &text, const std::string &part) {
63
70
  return text.find(part) != std::string::npos;
64
71
  }
65
72
 
73
+ // Columns of one trace_bunches call without roulette: positions and their row count.
74
+ struct Traced {
75
+ std::vector<double> position;
76
+ std::size_t rows{};
77
+ };
78
+
79
+ Traced trace_bunches_of(const OpticalModel &model, const BunchBlock &in, const BunchFrame &frame) {
80
+ Traced out;
81
+ ThreadPool pool(2);
82
+ std::vector<double> direction, time, weight, wavelength;
83
+ std::vector<std::uint64_t> bunch;
84
+ std::vector<std::uint32_t> sub;
85
+ const auto allocate = [&](std::size_t rows) {
86
+ out.position.resize(3 * rows);
87
+ direction.resize(3 * rows);
88
+ time.resize(rows);
89
+ weight.resize(rows);
90
+ wavelength.resize(rows);
91
+ bunch.resize(rows);
92
+ sub.resize(rows);
93
+ return BunchResult{out.position.data(), direction.data(), time.data(), weight.data(),
94
+ wavelength.data(), bunch.data(), sub.data()};
95
+ };
96
+ out.rows = trace_bunches(model, in, frame, BunchOptions{}, 7, pool, allocate);
97
+ return out;
98
+ }
99
+
100
+ // The loaded axes offset is applied by the tracer: a ground point P is traced at R P - O, the
101
+ // same as the model without offset and frame offset -O.
102
+ void axes_offset_is_applied_by_the_tracer() {
103
+ const std::string key = "\"optics\": {";
104
+ std::string text = kValid;
105
+ text.insert(text.find(key) + key.size(), "\n \"axes_offset_m\": [0.5, -0.2, 0.1],");
106
+ auto shifted = load_model(text);
107
+ auto plain = load_model(kValid);
108
+ CHECK((shifted.axes_offset_m == std::array<double, 3>{0.5, -0.2, 0.1}));
109
+ CHECK((plain.axes_offset_m == std::array<double, 3>{}));
110
+ // Without the obscurers, which would shadow the photons, the photons reach the primary.
111
+ shifted.obscurers = Obscurers(std::vector<Obscurer>{}, std::vector<Plane>{});
112
+ plain.obscurers = Obscurers(std::vector<Obscurer>{}, std::vector<Plane>{});
113
+
114
+ constexpr std::size_t n = 200;
115
+ std::vector<float> x(n), y(n), zero(n, 0.0F), size(n, 1.0F), wavelength(n, 400.0F), time(n, 0.0F);
116
+ std::vector<std::uint64_t> ids(n);
117
+ for (std::size_t i = 0; i < n; ++i) {
118
+ x[i] = static_cast<float>(0.2 + 0.6 * static_cast<double>(i) / n);
119
+ y[i] = static_cast<float>(-0.4 + 0.8 * static_cast<double>((37 * i) % n) / n);
120
+ ids[i] = i;
121
+ }
122
+ const BunchBlock in{x.data(), y.data(), zero.data(), zero.data(), time.data(),
123
+ zero.data(), size.data(), wavelength.data(), ids.data(), n};
124
+ BunchFrame origin;
125
+ origin.plane_z = 30.0;
126
+ BunchFrame explicit_offset = origin;
127
+ explicit_offset.offset_m[0] = -0.5;
128
+ explicit_offset.offset_m[1] = 0.2;
129
+ explicit_offset.offset_m[2] = -0.1;
130
+
131
+ const Traced offset_model = trace_bunches_of(shifted, in, origin);
132
+ const Traced reference = trace_bunches_of(plain, in, explicit_offset);
133
+ CHECK(offset_model.rows > 0);
134
+ CHECK(offset_model.rows == reference.rows);
135
+ CHECK(offset_model.position.size() == reference.position.size());
136
+ double largest = 0.0;
137
+ for (std::size_t i = 0; i < std::min(offset_model.position.size(), reference.position.size());
138
+ ++i)
139
+ largest = std::max(largest, std::abs(offset_model.position[i] - reference.position[i]));
140
+ CHECK(largest <= 1e-12);
141
+ // The offset is not a no-op: the same model without it traces the photons elsewhere.
142
+ const Traced unshifted = trace_bunches_of(plain, in, origin);
143
+ CHECK(unshifted.position != offset_model.position);
144
+ }
145
+
66
146
  } // namespace
67
147
 
68
148
  void all_tests() {
@@ -92,6 +172,9 @@ void all_tests() {
92
172
  CHECK(contains(error_after(R"("plane_z_m": 16.0)", R"("asphere_x": 1)"),
93
173
  "focal_surface: unknown key"));
94
174
  CHECK(contains(error_after("\"optics\": {", "\"optics\": {,"), "JSON syntax error"));
175
+ CHECK(contains(error_after("\"optics\": {", "\"optics\": {\"axes_offset_m\": [1, 2],"),
176
+ "expected three components"));
177
+ axes_offset_is_applied_by_the_tracer();
95
178
  }
96
179
 
97
180
  int main() { return run_checks(all_tests); }
@@ -591,7 +591,53 @@ template <class F> bool throws_invalid_argument(F &&f) {
591
591
  return false;
592
592
  }
593
593
 
594
- // trace_bunches rejects a wavelength density reaching 0 nm and bad acceptance axes.
594
+ // Output allocation equals the number of thinning survivors, including fractional bunches.
595
+ void bunch_survivor_allocation() {
596
+ constexpr std::size_t n = 2050;
597
+ const auto model = single_facet(10.0, 0.5);
598
+ std::vector<float> zero(n, 0.0F), size(n, 2.5F), wavelength(n, 400.0F);
599
+ std::vector<std::uint64_t> ids(n);
600
+ for (std::size_t i = 0; i < n; ++i)
601
+ ids[i] = i;
602
+ BunchBlock in{zero.data(), zero.data(), zero.data(), zero.data(), zero.data(),
603
+ zero.data(), size.data(), wavelength.data(), ids.data(), n};
604
+ const Map2D acceptance{{100.0, 1000.0}, {0.0, 1.0}, std::vector<double>(4, 0.2), false};
605
+ BunchOptions options;
606
+ options.acceptance = &acceptance;
607
+ BunchFrame frame;
608
+ frame.plane_z = 20.0;
609
+ constexpr std::uint64_t seed = 42;
610
+ std::size_t expected = 0;
611
+ const double bound = response_bound(model, 400.0);
612
+ for (std::size_t i = 0; i < n; ++i)
613
+ for (std::uint32_t k = 0; k < 3; ++k)
614
+ expected +=
615
+ uniform4(seed, ids[i], Stream::thinning, k)[0] < (k == 2 ? 0.5 : 1.0) * 0.2 * bound;
616
+ for (const std::size_t threads : {1, 6}) {
617
+ ThreadPool pool(threads);
618
+ std::vector<double> position, direction, time, weight, lambda;
619
+ std::vector<std::uint64_t> bunch;
620
+ std::vector<std::uint32_t> sub;
621
+ std::size_t allocated = 0;
622
+ const auto allocate = [&](std::size_t rows) {
623
+ allocated = rows;
624
+ position.resize(3 * rows);
625
+ direction.resize(3 * rows);
626
+ time.resize(rows);
627
+ weight.resize(rows);
628
+ lambda.resize(rows);
629
+ bunch.resize(rows);
630
+ sub.resize(rows);
631
+ return BunchResult{position.data(), direction.data(), time.data(), weight.data(),
632
+ lambda.data(), bunch.data(), sub.data()};
633
+ };
634
+ const auto written = trace_bunches(model, in, frame, options, seed, pool, allocate);
635
+ CHECK(allocated == expected);
636
+ CHECK(written <= allocated);
637
+ }
638
+ }
639
+
640
+ // trace_bunches rejects a wavelength density or law reaching 0 nm and bad acceptance axes.
595
641
  void bunch_option_validation() {
596
642
  const auto model = single_facet(10.0, 0.5);
597
643
  const float zero = 0.0F, one = 1.0F;
@@ -617,6 +663,11 @@ void bunch_option_validation() {
617
663
  options.wavelength_density = &from_below;
618
664
  CHECK(throws_invalid_argument([&] { run(options); }));
619
665
 
666
+ const InverseSquareLaw from_sub_nm{0.5, 0.75};
667
+ options.wavelength_density = nullptr;
668
+ options.wavelength_law = &from_sub_nm;
669
+ CHECK(throws_invalid_argument([&] { run(options); }));
670
+
620
671
  const double nan = std::numeric_limits<double>::quiet_NaN();
621
672
  const std::vector<double> ok{100.0, 1000.0};
622
673
  for (const auto &[x, y] : {std::pair{std::vector<double>{100.0, 100.0}, ok},
@@ -628,6 +679,17 @@ void bunch_option_validation() {
628
679
  bad.acceptance = &acceptance;
629
680
  CHECK(throws_invalid_argument([&] { run(bad); }));
630
681
  }
682
+
683
+ // An unsupported telescope transmission is rejected with ModelError before any bunch is traced.
684
+ auto unsupported = model;
685
+ unsupported.telescope_transmission = TelescopeTransmission{1.0, -1.0, 0.1, 2.0, 1.0};
686
+ bool model_error = false;
687
+ try {
688
+ trace_bunches(unsupported, in, frame, BunchOptions{}, 1, pool, allocate);
689
+ } catch (const ModelError &) {
690
+ model_error = true;
691
+ }
692
+ CHECK(model_error);
631
693
  }
632
694
 
633
695
  } // namespace
@@ -645,6 +707,7 @@ void all_tests() {
645
707
  thread_pool();
646
708
  thread_pool_exceptions();
647
709
  bunch_option_validation();
710
+ bunch_survivor_allocation();
648
711
  }
649
712
 
650
713
  int main() { return run_checks(all_tests); }