• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

openmc-dev / openmc / 40401946814

26 Oct 2026 09:09PM UTC coverage: 81.758% (+0.4%) from 81.391%
40401946814

Pull #3944

github

web-flow
Merge 644b8a937 into c6301133b
Pull Request #3944: DNP drift (regular mesh only)

20957 of 30134 branches covered (69.55%)

Branch coverage included in aggregate %.

1027 of 1210 new or added lines in 24 files covered. (84.88%)

880 existing lines in 20 files now uncovered.

64336 of 74190 relevant lines covered (86.72%)

40098224.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.32
/src/source.cpp
1
#include "openmc/source.h"
2

3
#if defined(__unix__) || (defined(__APPLE__) && defined(__MACH__))
4
#define HAS_DYNAMIC_LINKING
5
#endif
6

7
#include <algorithm> // for max
8
#include <cmath>     // for sin, cos, abs
9
#include <numeric>   // for accumulate
10
#include <utility>   // for move
11

12
#ifdef HAS_DYNAMIC_LINKING
13
#include <dlfcn.h> // for dlopen, dlsym, dlclose, dlerror
14
#endif
15

16
#include "openmc/tensor.h"
17
#include <fmt/core.h>
18

19
#include "openmc/bank.h"
20
#include "openmc/capi.h"
21
#include "openmc/cell.h"
22
#include "openmc/constants.h"
23
#include "openmc/container_util.h"
24
#include "openmc/error.h"
25
#include "openmc/file_utils.h"
26
#include "openmc/geometry.h"
27
#include "openmc/hdf5_interface.h"
28
#include "openmc/material.h"
29
#include "openmc/math_functions.h"
30
#include "openmc/mcpl_interface.h"
31
#include "openmc/memory.h"
32
#include "openmc/message_passing.h"
33
#include "openmc/mgxs_interface.h"
34
#include "openmc/nuclide.h"
35
#include "openmc/random_dist.h"
36
#include "openmc/random_lcg.h"
37
#include "openmc/search.h"
38
#include "openmc/settings.h"
39
#include "openmc/simulation.h"
40
#include "openmc/state_point.h"
41
#include "openmc/string_utils.h"
42
#include "openmc/surface.h"
43
#include "openmc/xml_interface.h"
44

45
namespace openmc {
46

47
std::atomic<int64_t> source_n_accept {0};
48
std::atomic<int64_t> source_n_reject {0};
49

50
namespace {
51

52
void validate_particle_type(ParticleType type, const std::string& context)
175,586 ✔
53
{
54
  if (type.is_transportable())
175,586 !
55
    return;
175,586 ✔
56

57
  fatal_error(
×
58
    fmt::format("Unsupported source particle type '{}' (PDG {}) in {}.",
×
UNCOV
59
      type.str(), type.pdg_number(), context));
×
60
}
61

62
} // namespace
63

64
//==============================================================================
65
// Global variables
66
//==============================================================================
67

68
namespace model {
69

70
vector<unique_ptr<Source>> external_sources;
71

72
vector<unique_ptr<Source>> adjoint_sources;
73

74
DiscreteIndex external_sources_probability;
75

76
} // namespace model
77

78
//==============================================================================
79
// Source implementation
80
//==============================================================================
81

82
Source::Source(pugi::xml_node node)
53,088 ✔
83
{
84
  // Check for source strength
85
  if (check_for_node(node, "strength")) {
53,088 ✔
86
    strength_ = std::stod(get_node_value(node, "strength"));
105,636 ✔
87
    if (strength_ < 0.0) {
52,818 !
UNCOV
88
      fatal_error("Source strength is negative.");
×
89
    }
90
  }
91

92
  // Check for additional defined constraints
93
  read_constraints(node);
53,088 ✔
94
}
53,088 ✔
95

96
unique_ptr<Source> Source::create(pugi::xml_node node)
53,088 ✔
97
{
98
  // if the source type is present, use it to determine the type
99
  // of object to create
100
  if (check_for_node(node, "type")) {
53,088 ✔
101
    std::string source_type = get_node_value(node, "type");
52,725 ✔
102
    if (source_type == "independent") {
52,725 ✔
103
      return make_unique<IndependentSource>(node);
52,535 ✔
104
    } else if (source_type == "file") {
190 ✔
105
      return make_unique<FileSource>(node);
49 ✔
106
    } else if (source_type == "compiled") {
141 ✔
107
      return make_unique<CompiledSourceWrapper>(node);
12 ✔
108
    } else if (source_type == "mesh") {
129 ✔
109
      return make_unique<MeshSource>(node);
94 ✔
110
    } else if (source_type == "tokamak") {
35 ✔
111
      return make_unique<TokamakSource>(node);
20 ✔
112
    } else if (source_type == "stellarator") {
15 !
113
      return make_unique<StellaratorSource>(node);
15 ✔
114
    } else {
UNCOV
115
      fatal_error(fmt::format("Invalid source type '{}' found.", source_type));
×
116
    }
117
  } else {
52,721 ✔
118
    // support legacy source format
119
    if (check_for_node(node, "file")) {
363 ✔
120
      return make_unique<FileSource>(node);
12 ✔
121
    } else if (check_for_node(node, "library")) {
351 !
UNCOV
122
      return make_unique<CompiledSourceWrapper>(node);
×
123
    } else {
124
      return make_unique<IndependentSource>(node);
351 ✔
125
    }
126
  }
127
}
128

129
void Source::read_constraints(pugi::xml_node node)
53,088 ✔
130
{
131
  // Check for constraints node. For backwards compatibility, if no constraints
132
  // node is given, still try searching for domain constraints from top-level
133
  // node.
134
  pugi::xml_node constraints_node = node.child("constraints");
53,088 ✔
135
  if (constraints_node) {
53,088 ✔
136
    node = constraints_node;
1,129 ✔
137
  }
138

139
  // Check for domains to reject from
140
  if (check_for_node(node, "domain_type")) {
53,088 ✔
141
    std::string domain_type = get_node_value(node, "domain_type");
317 ✔
142
    if (domain_type == "cell") {
317 ✔
143
      domain_type_ = DomainType::CELL;
65 ✔
144
    } else if (domain_type == "material") {
252 ✔
145
      domain_type_ = DomainType::MATERIAL;
33 ✔
146
    } else if (domain_type == "universe") {
219 !
147
      domain_type_ = DomainType::UNIVERSE;
219 ✔
148
    } else {
UNCOV
149
      fatal_error(
×
UNCOV
150
        std::string("Unrecognized domain type for constraint: " + domain_type));
×
151
    }
152

153
    auto ids = get_node_array<int>(node, "domain_ids");
317 ✔
154
    domain_ids_.insert(ids.begin(), ids.end());
317 ✔
155
  }
317 ✔
156

157
  if (check_for_node(node, "time_bounds")) {
53,088 ✔
158
    auto ids = get_node_array<double>(node, "time_bounds");
5 ✔
159
    if (ids.size() != 2) {
5 !
UNCOV
160
      fatal_error("Time bounds must be represented by two numbers.");
×
161
    }
162
    time_bounds_ = std::make_pair(ids[0], ids[1]);
5 ✔
163
  }
5 ✔
164
  if (check_for_node(node, "energy_bounds")) {
53,088 ✔
165
    auto ids = get_node_array<double>(node, "energy_bounds");
5 ✔
166
    if (ids.size() != 2) {
5 !
UNCOV
167
      fatal_error("Energy bounds must be represented by two numbers.");
×
168
    }
169
    energy_bounds_ = std::make_pair(ids[0], ids[1]);
5 ✔
170
  }
5 ✔
171

172
  if (check_for_node(node, "fissionable")) {
53,088 ✔
173
    only_fissionable_ = get_node_value_bool(node, "fissionable");
807 ✔
174
  }
175

176
  // Check for how to handle rejected particles
177
  if (check_for_node(node, "rejection_strategy")) {
53,088 !
178
    std::string rejection_strategy = get_node_value(node, "rejection_strategy");
×
179
    if (rejection_strategy == "kill") {
×
UNCOV
180
      rejection_strategy_ = RejectionStrategy::KILL;
×
181
    } else if (rejection_strategy == "resample") {
×
UNCOV
182
      rejection_strategy_ = RejectionStrategy::RESAMPLE;
×
183
    } else {
184
      fatal_error(std::string(
×
185
        "Unrecognized strategy source rejection: " + rejection_strategy));
186
    }
UNCOV
187
  }
×
188
}
53,088 ✔
189

190
void check_rejection_fraction(int64_t n_reject, int64_t n_accept)
18,338,070 ✔
191
{
192
  // Don't check unless we've hit a minimum number of total sites rejected
193
  if (n_reject < EXTSRC_REJECT_THRESHOLD)
18,338,070 ✔
194
    return;
195

196
  // Compute fraction of accepted sites and compare against minimum
197
  double fraction = static_cast<double>(n_accept) / n_reject;
601,686 ✔
198
  if (fraction <= settings::source_rejection_fraction) {
601,686 ✔
199
    fatal_error(fmt::format(
3 ✔
200
      "Too few source sites satisfied the constraints (minimum source "
201
      "rejection fraction = {}). Please check your source definition or "
202
      "set a lower value of Settings.source_rejection_fraction.",
203
      settings::source_rejection_fraction));
204
  }
205
}
206

207
SourceSite Source::sample_with_constraints(uint64_t* seed) const
18,338,070 ✔
208
{
209
  bool accepted = false;
18,338,070 ✔
210
  int64_t n_local_reject = 0;
18,338,070 ✔
211
  SourceSite site {};
18,338,070 ✔
212

213
  while (!accepted) {
55,625,645 ✔
214
    // Sample a source site without considering constraints yet
215
    site = this->sample(seed);
18,949,505 ✔
216

217
    if (constraints_applied()) {
18,949,505 ✔
218
      accepted = true;
219
    } else {
220
      // Check whether sampled site satisfies constraints
221
      accepted = satisfies_spatial_constraints(site.r) &&
24,257,005 ✔
222
                 satisfies_energy_constraints(site.E) &&
3,737,219 ✔
223
                 satisfies_time_constraints(site.time);
1,565,444 ✔
224
      if (!accepted) {
611,435 ✔
225
        ++n_local_reject;
611,435 ✔
226

227
        // Check per-particle rejection limit
228
        if (n_local_reject >= MAX_SOURCE_REJECTIONS_PER_SAMPLE) {
611,435 !
UNCOV
229
          fatal_error("Exceeded maximum number of source rejections per "
×
230
                      "sample. Please check your source definition.");
231
        }
232

233
        // For the "kill" strategy, accept particle but set weight to 0 so that
234
        // it is terminated immediately
235
        if (rejection_strategy_ == RejectionStrategy::KILL) {
611,435 !
UNCOV
236
          accepted = true;
×
UNCOV
237
          site.wgt = 0.0;
×
238
        }
239
      }
240
    }
241
  }
242

243
  // Flush local rejection count, update accept counter, and check overall
244
  // rejection fraction
245
  if (n_local_reject > 0) {
18,338,070 ✔
246
    source_n_reject += n_local_reject;
8,732 ✔
247
  }
248
  ++source_n_accept;
18,338,070 ✔
249
  check_rejection_fraction(source_n_reject, source_n_accept);
18,338,070 ✔
250

251
  return site;
18,338,067 ✔
252
}
253

254
bool Source::satisfies_energy_constraints(double E) const
18,353,115 ✔
255
{
256
  return E > energy_bounds_.first && E < energy_bounds_.second;
18,353,115 !
257
}
258

259
bool Source::satisfies_time_constraints(double time) const
1,565,444 ✔
260
{
261
  return time > time_bounds_.first && time < time_bounds_.second;
1,565,444 ✔
262
}
263

264
bool Source::satisfies_spatial_constraints(Position r) const
20,741,197 ✔
265
{
266
  GeometryState geom_state;
20,741,197 ✔
267
  geom_state.r() = r;
20,741,197 ✔
268
  geom_state.u() = {0.0, 0.0, 1.0};
20,741,197 ✔
269

270
  // Reject particle if it's not in the geometry at all
271
  bool found = exhaustive_find_cell(geom_state);
20,741,197 ✔
272
  if (!found)
20,741,197 ✔
273
    return false;
274

275
  // Check the geometry state against specified domains
276
  bool accepted = true;
20,511,339 ✔
277
  if (!domain_ids_.empty()) {
20,511,339 ✔
278
    if (domain_type_ == DomainType::MATERIAL) {
935,799 ✔
279
      auto mat_index = geom_state.material();
5,000 ✔
280
      if (mat_index == MATERIAL_VOID) {
5,000 !
281
        accepted = false;
282
      } else {
283
        accepted = contains(domain_ids_, model::materials[mat_index]->id());
10,000 ✔
284
      }
285
    } else {
286
      for (int i = 0; i < geom_state.n_coord(); i++) {
1,837,675 ✔
287
        auto id =
930,799 ✔
288
          (domain_type_ == DomainType::CELL)
289
            ? model::cells[geom_state.coord(i).cell()].get()->id_
930,799 !
UNCOV
290
            : model::universes[geom_state.coord(i).universe()].get()->id_;
×
291
        if ((accepted = contains(domain_ids_, id)))
1,861,598 ✔
292
          break;
293
      }
294
    }
295
  }
296

297
  // Check if spatial site is in fissionable material
298
  if (accepted && only_fissionable_) {
20,511,339 ✔
299
    // Determine material
300
    auto mat_index = geom_state.material();
569,436 ✔
301
    if (mat_index == MATERIAL_VOID) {
569,436 !
302
      accepted = false;
303
    } else {
304
      accepted = model::materials[mat_index]->fissionable();
569,436 ✔
305
    }
306
  }
307

308
  return accepted;
309
}
20,741,197 ✔
310

311
//==============================================================================
312
// IndependentSource implementation
313
//==============================================================================
314

315
IndependentSource::IndependentSource(
1,098 ✔
316
  UPtrSpace space, UPtrAngle angle, UPtrDist energy, UPtrDist time)
1,098 ✔
317
  : space_ {std::move(space)}, angle_ {std::move(angle)},
1,098 ✔
318
    energy_ {std::move(energy)}, time_ {std::move(time)}
1,098 ✔
319
{}
1,098 ✔
320

321
IndependentSource::IndependentSource(pugi::xml_node node) : Source(node)
52,886 ✔
322
{
323
  // Check for particle type
324
  if (check_for_node(node, "particle")) {
52,886 ✔
325
    auto temp_str = get_node_value(node, "particle", false, true);
52,535 ✔
326
    particle_ = ParticleType(temp_str);
52,535 ✔
327
    if (particle_ == ParticleType::photon() ||
52,535 ✔
328
        particle_ == ParticleType::electron() ||
52,535 ✔
329
        particle_ == ParticleType::positron()) {
52,391 !
330
      settings::photon_transport = true;
144 ✔
331
    }
332
  }
52,535 ✔
333
  validate_particle_type(particle_, "IndependentSource");
52,886 ✔
334

335
  // Check for external source file
336
  if (check_for_node(node, "file")) {
52,886 !
337

338
  } else {
339

340
    // Spatial distribution for external source
341
    if (check_for_node(node, "space")) {
52,886 ✔
342
      space_ = SpatialDistribution::create(node.child("space"));
3,760 ✔
343
    } else {
344
      // If no spatial distribution specified, make it a point source
345
      space_ = UPtrSpace {new SpatialPoint()};
49,126 ✔
346
    }
347

348
    // For backwards compatibility, check for only fissionable setting on box
349
    // source
350
    auto space_box = dynamic_cast<SpatialBox*>(space_.get());
52,886 !
351
    if (space_box) {
52,886 ✔
352
      if (!only_fissionable_) {
2,083 ✔
353
        only_fissionable_ = space_box->only_fissionable();
1,276 ✔
354
      }
355
    }
356

357
    // Determine external source angular distribution
358
    if (check_for_node(node, "angle")) {
52,886 ✔
359
      angle_ = UnitSphereDistribution::create(node.child("angle"));
1,568 ✔
360
    } else {
361
      angle_ = UPtrAngle {new Isotropic()};
51,318 ✔
362
    }
363

364
    // Determine external source energy distribution
365
    if (check_for_node(node, "energy")) {
52,886 ✔
366
      pugi::xml_node node_dist = node.child("energy");
2,445 ✔
367
      energy_ = distribution_from_xml(node_dist);
2,445 ✔
368

369
      // For decay photon sources, use the absolute photon emission rate in
370
      // [photons/s] as the source strength
371
      if (dynamic_cast<DecaySpectrum*>(energy_.get())) {
2,445 !
372
        if (strength_ != 1.0) {
25 !
UNCOV
373
          warning(fmt::format(
×
374
            "Source strength of {} is ignored because the source uses a "
375
            "DecaySpectrum energy distribution. The source strength will be "
376
            "set from the DecaySpectrum emission rate.",
UNCOV
377
            strength_));
×
378
        }
379
        strength_ = energy_->integral();
25 ✔
380
      }
381
    } else {
382
      // Default to a Watt spectrum with parameters 0.988 MeV and 2.249 MeV^-1
383
      energy_ = UPtrDist {new Watt(0.988e6, 2.249e-6)};
50,441 ✔
384
    }
385

386
    // Determine external source time distribution
387
    if (check_for_node(node, "time")) {
52,886 ✔
388
      pugi::xml_node node_dist = node.child("time");
17 ✔
389
      time_ = distribution_from_xml(node_dist);
17 ✔
390
    } else {
391
      // Default to a Constant time T=0
392
      double T[] {0.0};
52,869 ✔
393
      double p[] {1.0};
52,869 ✔
394
      time_ = UPtrDist {new Discrete {T, p, 1}};
52,869 ✔
395
    }
396
  }
397
}
52,886 ✔
398

399
SourceSite IndependentSource::sample(uint64_t* seed) const
17,843,050 ✔
400
{
401
  SourceSite site {};
17,843,050 ✔
402
  site.particle = particle_;
17,843,050 ✔
403
  double r_wgt = 1.0;
17,843,050 ✔
404
  double E_wgt = 1.0;
17,843,050 ✔
405

406
  // Repeat sampling source location until a good site has been accepted
407
  bool accepted = false;
17,843,050 ✔
408
  int64_t n_local_reject = 0;
17,843,050 ✔
409

410
  while (!accepted) {
36,381,100 ✔
411

412
    // Sample spatial distribution
413
    auto [r, r_wgt_temp] = space_->sample(seed);
18,538,050 ✔
414
    site.r = r;
18,538,050 ✔
415
    r_wgt = r_wgt_temp;
18,538,050 ✔
416

417
    // Check if sampled position satisfies spatial constraints
418
    accepted = satisfies_spatial_constraints(site.r);
18,538,050 ✔
419

420
    // Check for rejection
421
    if (!accepted) {
18,538,050 ✔
422
      ++n_local_reject;
695,000 ✔
423
      if (n_local_reject >= MAX_SOURCE_REJECTIONS_PER_SAMPLE) {
695,000 !
UNCOV
424
        fatal_error("Exceeded maximum number of source rejections per "
×
425
                    "sample. Please check your source definition.");
426
      }
427
    }
428
  }
429

430
  // Sample angle
431
  auto [u, u_wgt] = angle_->sample(seed);
17,843,050 ✔
432
  site.u = u;
17,843,050 ✔
433

434
  site.wgt = r_wgt * u_wgt;
17,843,050 ✔
435

436
  // Sample energy and time for neutron and photon sources
437
  if (settings::solver_type != SolverType::RANDOM_RAY) {
17,843,050 ✔
438
    // Check for monoenergetic source above maximum particle energy
439
    auto p = particle_.transport_index();
16,777,730 ✔
440
    auto energy_ptr = dynamic_cast<Discrete*>(energy_.get());
16,777,730 !
441
    auto decay_spectrum = dynamic_cast<DecaySpectrum*>(energy_.get());
16,777,730 !
442
    if (energy_ptr) {
16,777,730 ✔
443
      auto energies =
9,500,590 ✔
444
        tensor::Tensor<double>(energy_ptr->x().data(), energy_ptr->x().size());
9,500,590 ✔
445
      if ((energies > data::energy_max[p]).any()) {
28,501,770 !
UNCOV
446
        fatal_error("Source energy above range of energies of at least "
×
447
                    "one cross section table");
448
      }
449
    }
9,500,590 ✔
450

451
    while (true) {
16,777,730 ✔
452
      // Sample energy spectrum. For decay photon sources, also get the parent
453
      // nuclide index to store in the source site for tallying purposes.
454
      if (decay_spectrum) {
16,777,730 ✔
455
        auto sample = decay_spectrum->sample_with_parent(seed);
32,500 ✔
456
        site.E = sample.energy;
32,500 ✔
457
        E_wgt = sample.weight;
32,500 ✔
458
        site.parent_nuclide = sample.parent_nuclide;
32,500 ✔
459
      } else {
460
        auto [E, E_wgt_temp] = energy_->sample(seed);
16,745,230 ✔
461
        site.E = E;
16,745,230 ✔
462
        E_wgt = E_wgt_temp;
16,745,230 ✔
463
      }
464

465
      // Resample if energy falls above maximum particle energy
466
      if (site.E < data::energy_max[p] &&
33,555,460 !
467
          (satisfies_energy_constraints(site.E)))
16,777,730 ✔
468
        break;
469

UNCOV
470
      ++n_local_reject;
×
UNCOV
471
      if (n_local_reject >= MAX_SOURCE_REJECTIONS_PER_SAMPLE) {
×
UNCOV
472
        fatal_error("Exceeded maximum number of source rejections per "
×
473
                    "sample. Please check your source definition.");
474
      }
475
    }
476

477
    // Sample particle creation time
478
    auto [time, time_wgt] = time_->sample(seed);
16,777,730 ✔
479
    site.time = time;
16,777,730 ✔
480

481
    site.wgt *= (E_wgt * time_wgt);
16,777,730 ✔
482
  }
483

484
  // Flush local rejection count into global counter
485
  if (n_local_reject > 0) {
17,843,050 ✔
486
    source_n_reject += n_local_reject;
168,340 ✔
487
  }
488

489
  return site;
17,843,050 ✔
490
}
491

492
//==============================================================================
493
// FileSource implementation
494
//==============================================================================
495

496
FileSource::FileSource(pugi::xml_node node) : Source(node)
61 ✔
497
{
498
  auto path = get_node_value(node, "file", false, true);
61 ✔
499
  load_sites_from_file(path);
61 ✔
500
}
57 ✔
501

502
FileSource::FileSource(const std::string& path)
12 ✔
503
{
504
  load_sites_from_file(path);
12 ✔
505
}
12 ✔
506

507
void FileSource::load_sites_from_file(const std::string& path)
73 ✔
508
{
509
  // If MCPL file, use the dedicated file reader
510
  if (ends_with(path, ".mcpl") || ends_with(path, ".mcpl.gz")) {
134 !
511
    sites_ = mcpl_source_sites(path);
12 ✔
512
  } else {
513
    // Check if source file exists
514
    if (!file_exists(path)) {
61 !
UNCOV
515
      fatal_error(fmt::format("Source file '{}' does not exist.", path));
×
516
    }
517

518
    write_message(6, "Reading source file from {}...", path);
61 ✔
519

520
    // Open the binary file
521
    hid_t file_id = file_open(path, 'r', true);
61 ✔
522

523
    // Check to make sure this is a source file
524
    std::string filetype;
61 ✔
525
    read_attribute(file_id, "filetype", filetype);
61 ✔
526
    if (filetype != "source" && filetype != "statepoint") {
61 !
UNCOV
527
      fatal_error("Specified starting source file not a source file type.");
×
528
    }
529

530
    // Read in the source particles
531
    read_source_bank(file_id, sites_, false);
61 ✔
532

533
    // Close file
534
    file_close(file_id);
57 ✔
535
  }
57 ✔
536

537
  // Make sure particles in source file have valid types. If any particle is a
538
  // photon, electron, or positron, enable photon transport so that the
539
  // appropriate cross sections are loaded.
540
  for (const auto& site : this->sites_) {
74,109 ✔
541
    validate_particle_type(site.particle, "FileSource");
74,040 ✔
542
    if (site.particle == ParticleType::photon() ||
74,040 ✔
543
        site.particle == ParticleType::electron() ||
74,040 !
544
        site.particle == ParticleType::positron()) {
74,035 !
545
      settings::photon_transport = true;
5 ✔
546
    }
547
  }
548
}
69 ✔
549

550
SourceSite FileSource::sample(uint64_t* seed) const
135,345 ✔
551
{
552
  // Sample a particle randomly from list
553
  size_t i_site = sites_.size() * prn(seed);
135,345 ✔
554
  SourceSite site = sites_[i_site];
135,345 ✔
555

556
  // Surface source files store unsigned surface IDs. If the ID refers to a CSG
557
  // surface containing the source site, determine the signed half-space from
558
  // the particle direction. Otherwise, ignore the surface ID and allow the
559
  // normal cell search to locate the particle.
560
  if (site.surf_id != SURFACE_NONE) {
135,345 ✔
561
    auto it = model::surface_map.find(std::abs(site.surf_id));
55,000 ✔
562
    if (it != model::surface_map.end()) {
55,000 ✔
563
      const auto& surf = *model::surfaces[it->second];
53,365 ✔
564
      if (surf.geom_type() == GeometryType::CSG &&
106,730 !
565
          std::abs(surf.evaluate(site.r)) < FP_COINCIDENT) {
53,365 ✔
566
        int surf_id = std::abs(site.surf_id);
51,725 ✔
567
        site.surf_id =
103,450 ✔
568
          (site.u.dot(surf.normal(site.r)) > 0.0) ? surf_id : -surf_id;
51,725 ✔
569
        return site;
51,725 ✔
570
      }
571
    }
572
    site.surf_id = SURFACE_NONE;
3,275 ✔
573
  }
574

575
  return site;
576
}
577

578
//==============================================================================
579
// CompiledSourceWrapper implementation
580
//==============================================================================
581

582
CompiledSourceWrapper::CompiledSourceWrapper(pugi::xml_node node) : Source(node)
12 ✔
583
{
584
  // Get shared library path and parameters
585
  auto path = get_node_value(node, "library", false, true);
12 ✔
586
  std::string parameters;
12 ✔
587
  if (check_for_node(node, "parameters")) {
12 ✔
588
    parameters = get_node_value(node, "parameters", false, true);
6 ✔
589
  }
590
  setup(path, parameters);
12 ✔
591
}
12 ✔
592

593
void CompiledSourceWrapper::setup(
12 ✔
594
  const std::string& path, const std::string& parameters)
595
{
596
#ifdef HAS_DYNAMIC_LINKING
597
  // Open the library
598
  shared_library_ = dlopen(path.c_str(), RTLD_LAZY);
12 ✔
599
  if (!shared_library_) {
12 !
UNCOV
600
    fatal_error("Couldn't open source library " + path);
×
601
  }
602

603
  // reset errors
604
  dlerror();
12 ✔
605

606
  // get the function to create the custom source from the library
607
  auto create_compiled_source = reinterpret_cast<create_compiled_source_t*>(
12 ✔
608
    dlsym(shared_library_, "openmc_create_source"));
12 ✔
609

610
  // check for any dlsym errors
611
  auto dlsym_error = dlerror();
12 ✔
612
  if (dlsym_error) {
12 !
613
    std::string error_msg = fmt::format(
×
614
      "Couldn't open the openmc_create_source symbol: {}", dlsym_error);
×
UNCOV
615
    dlclose(shared_library_);
×
UNCOV
616
    fatal_error(error_msg);
×
UNCOV
617
  }
×
618

619
  // create a pointer to an instance of the custom source
620
  compiled_source_ = create_compiled_source(parameters);
12 ✔
621

622
#else
623
  fatal_error("Custom source libraries have not yet been implemented for "
624
              "non-POSIX systems");
625
#endif
626
}
12 ✔
627

628
CompiledSourceWrapper::~CompiledSourceWrapper()
24 ✔
629
{
630
  // Make sure custom source is cleared before closing shared library
631
  if (compiled_source_.get())
12 !
632
    compiled_source_.reset();
12 ✔
633

634
#ifdef HAS_DYNAMIC_LINKING
635
  dlclose(shared_library_);
12 ✔
636
#else
637
  fatal_error("Custom source libraries have not yet been implemented for "
638
              "non-POSIX systems");
639
#endif
640
}
24 ✔
641

642
//==============================================================================
643
// MeshElementSpatial implementation
644
//==============================================================================
645

646
std::pair<Position, double> MeshElementSpatial::sample(uint64_t* seed) const
692,993 ✔
647
{
648
  return {model::meshes[mesh_index_]->sample_element(elem_index_, seed), 1.0};
692,993 ✔
649
}
650

651
//==============================================================================
652
// MeshSource implementation
653
//==============================================================================
654

655
MeshSource::MeshSource(pugi::xml_node node) : Source(node)
94 ✔
656
{
657
  int32_t mesh_id = stoi(get_node_value(node, "mesh"));
188 ✔
658
  int32_t mesh_idx = model::mesh_map.at(mesh_id);
94 ✔
659
  const auto& mesh = model::meshes[mesh_idx];
94 ✔
660

661
  std::vector<double> strengths;
94 ✔
662
  // read all source distributions and populate strengths vector for MeshSpatial
663
  // object
664
  for (auto source_node : node.children("source")) {
48,754 ✔
665
    auto src = Source::create(source_node);
48,660 ✔
666
    if (auto ptr = dynamic_cast<IndependentSource*>(src.get())) {
48,660 !
667
      src.release();
48,660 ✔
668
      sources_.emplace_back(ptr);
48,660 ✔
669
    } else {
UNCOV
670
      fatal_error(
×
671
        "The source assigned to each element must be an IndependentSource.");
672
    }
673
    strengths.push_back(sources_.back()->strength());
48,660 ✔
674
  }
48,660 ✔
675

676
  // Set spatial distributions for each mesh element
677
  for (int elem_index = 0; elem_index < sources_.size(); ++elem_index) {
48,754 ✔
678
    sources_[elem_index]->set_space(
48,660 ✔
679
      std::make_unique<MeshElementSpatial>(mesh_idx, elem_index));
97,320 ✔
680
  }
681

682
  // Make sure sources use valid particle types
683
  for (const auto& src : sources_) {
48,754 ✔
684
    validate_particle_type(src->particle_type(), "MeshSource");
97,320 ✔
685
  }
686

687
  // the number of source distributions should either be one or equal to the
688
  // number of mesh elements
689
  if (sources_.size() > 1 && sources_.size() != mesh->n_bins()) {
94 !
UNCOV
690
    fatal_error(fmt::format("Incorrect number of source distributions ({}) for "
×
691
                            "mesh source with {} elements.",
UNCOV
692
      sources_.size(), mesh->n_bins()));
×
693
  }
694

695
  space_ = std::make_unique<MeshSpatial>(mesh_idx, strengths);
94 ✔
696
}
94 ✔
697

698
SourceSite MeshSource::sample(uint64_t* seed) const
686,430 ✔
699
{
700
  // Sample a mesh element based on the relative strengths
701
  int32_t element = space_->sample_element_index(seed);
686,430 ✔
702

703
  // Sample the distribution for the specific mesh element; note that the
704
  // spatial distribution has been set for each element using MeshElementSpatial
705
  return source(element)->sample_with_constraints(seed);
1,372,860 !
706
}
707

708
//==============================================================================
709
// TokamakSource implementation
710
//==============================================================================
711

712
TokamakSource::TokamakSource(pugi::xml_node node) : Source(node)
20 ✔
713
{
714
  // Read geometry parameters
715
  major_radius_ = std::stod(get_node_value(node, "major_radius"));
40 ✔
716
  minor_radius_ = std::stod(get_node_value(node, "minor_radius"));
40 ✔
717
  elongation_ = std::stod(get_node_value(node, "elongation"));
40 ✔
718
  triangularity_ = std::stod(get_node_value(node, "triangularity"));
40 ✔
719
  shafranov_shift_ = std::stod(get_node_value(node, "shafranov_shift"));
40 ✔
720

721
  // Read optional vertical shift
722
  if (check_for_node(node, "vertical_shift")) {
20 ✔
723
    vertical_shift_ = std::stod(get_node_value(node, "vertical_shift"));
10 ✔
724
  } else {
725
    vertical_shift_ = 0.0;
15 ✔
726
  }
727

728
  // Read optional toroidal angle bounds
729
  if (check_for_node(node, "phi_start")) {
20 !
730
    phi_start_ = std::stod(get_node_value(node, "phi_start"));
40 ✔
731
  } else {
UNCOV
732
    phi_start_ = 0.0;
×
733
  }
734
  if (check_for_node(node, "phi_extent")) {
20 !
735
    phi_extent_ = std::stod(get_node_value(node, "phi_extent"));
40 ✔
736
  } else {
UNCOV
737
    phi_extent_ = 2.0 * PI;
×
738
  }
739
  if (check_for_node(node, "n_alpha")) {
20 !
740
    n_alpha_ = std::stoi(get_node_value(node, "n_alpha"));
40 ✔
741
  } else {
UNCOV
742
    n_alpha_ = 101; // Default
×
743
  }
744

745
  // Read emission profile
746
  r_over_a_ = get_node_array<double>(node, "r_over_a");
20 ✔
747
  emission_density_ = get_node_array<double>(node, "emission_density");
20 ✔
748

749
  // Read energy distribution(s)
750
  for (auto energy_node : node.children("energy")) {
40 ✔
751
    energy_dists_.push_back(distribution_from_xml(energy_node));
40 ✔
752
  }
753

754
  // Read optional time distribution; default to a delta distribution at t=0
755
  // for the same behavior as IndependentSource
756
  if (check_for_node(node, "time")) {
20 !
UNCOV
757
    time_ = distribution_from_xml(node.child("time"));
×
758
  } else {
759
    double T[] {0.0};
20 ✔
760
    double p[] {1.0};
20 ✔
761
    time_ = UPtrDist {new Discrete {T, p, 1}};
20 ✔
762
  }
763

764
  // Validate inputs
765
  if (emission_density_.size() != r_over_a_.size()) {
20 !
UNCOV
766
    fatal_error("TokamakSource: emission_density and r_over_a must have the "
×
767
                "same length.");
768
  }
769
  if (r_over_a_.size() < 2) {
20 !
UNCOV
770
    fatal_error(
×
771
      "TokamakSource: At least 2 radial points are required for profiles.");
772
  }
773
  if (r_over_a_.front() != 0.0) {
20 !
774
    fatal_error("TokamakSource: r_over_a must start at 0.");
×
775
  }
776
  if (r_over_a_.back() != 1.0) {
20 !
UNCOV
777
    fatal_error("TokamakSource: r_over_a must end at 1.");
×
778
  }
779
  for (size_t i = 1; i < r_over_a_.size(); ++i) {
1,070 ✔
780
    if (r_over_a_[i] <= r_over_a_[i - 1]) {
1,050 !
UNCOV
781
      fatal_error("TokamakSource: r_over_a must be strictly increasing.");
×
782
    }
783
  }
784
  for (size_t i = 0; i < emission_density_.size(); ++i) {
1,090 ✔
785
    if (emission_density_[i] < 0.0) {
1,070 !
UNCOV
786
      fatal_error("TokamakSource: emission_density values cannot be negative.");
×
787
    }
788
  }
789
  if (major_radius_ <= 0.0) {
20 !
790
    fatal_error("TokamakSource: major_radius must be > 0.");
×
791
  }
792
  if (minor_radius_ <= 0.0) {
20 !
793
    fatal_error("TokamakSource: minor_radius must be > 0.");
×
794
  }
795
  if (minor_radius_ >= major_radius_) {
20 !
796
    fatal_error("TokamakSource: minor_radius must be less than major_radius.");
×
797
  }
798
  if (elongation_ <= 0.0) {
20 !
799
    fatal_error("TokamakSource: elongation must be > 0.");
×
800
  }
801
  if (triangularity_ < -1.0 || triangularity_ > 1.0) {
20 !
802
    fatal_error("TokamakSource: triangularity must be in the range [-1, 1].");
×
803
  }
804
  if (shafranov_shift_ < 0.0) {
20 !
805
    fatal_error("TokamakSource: shafranov_shift must be >= 0.");
×
806
  }
807
  if (shafranov_shift_ >= 0.5 * minor_radius_) {
20 !
UNCOV
808
    fatal_error("TokamakSource: shafranov_shift must be less than half the "
×
809
                "minor radius.");
810
  }
811
  if (phi_extent_ <= 0.0 || phi_extent_ > 2.0 * PI) {
20 !
812
    fatal_error("TokamakSource: phi_extent must be > 0 and <= 2*pi.");
×
813
  }
814
  if (n_alpha_ <= 2) {
20 !
UNCOV
815
    fatal_error("TokamakSource: n_alpha must be > 2.");
×
816
  }
817
  if (n_alpha_ < 51) {
20 ✔
818
    warning("TokamakSource: n_alpha values below 51 may introduce noticeable "
10 ✔
819
            "discretization bias in source sampling.");
820
  }
821
  if (energy_dists_.empty()) {
20 !
822
    fatal_error("TokamakSource: At least one energy distribution is required.");
×
823
  }
824
  if (energy_dists_.size() != 1 && energy_dists_.size() != r_over_a_.size()) {
20 !
UNCOV
825
    fatal_error("TokamakSource: energy distributions must be either 1 (for all "
×
826
                "r) or match the number of r_over_a points.");
827
  }
828

829
  // Compute normalized geometry parameters
830
  epsilon_ = minor_radius_ / major_radius_;
20 ✔
831
  delta_tilde_ = shafranov_shift_ / minor_radius_;
20 ✔
832

833
  // Initialize isotropic angular distribution
834
  angle_ = UPtrAngle {new Isotropic()};
20 ✔
835

836
  precompute_sampling_distributions();
20 ✔
837
}
20 ✔
838

839
void TokamakSource::precompute_sampling_distributions()
20 ✔
840
{
841
  // Use precomputed normalized geometry parameters
842
  double eps = epsilon_;    // Inverse aspect ratio (a/R0)
20 ✔
843
  double Dt = delta_tilde_; // Normalized Shafranov shift (Delta/a)
20 ✔
844
  double delta = triangularity_;
20 ✔
845

846
  //==========================================================================
847
  // RADIAL CDF (computed first since it's simpler and sampled first)
848
  //==========================================================================
849
  // The marginal radial PDF is obtained by analytically integrating the joint
850
  // distribution f(r_tilde, alpha) over alpha. The result is:
851
  //
852
  //   p(r_tilde) ~ S(r_tilde) * [(1 + eps*Dt)*r_tilde
853
  //                              - (3/8)*c1*eps*r_tilde^2
854
  //                              - 2*eps*Dt*r_tilde^3]
855
  //
856
  // where the Bessel function coefficients are:
857
  //   c0 = J_0(delta) + J_2(delta)
858
  //   c1 = (J_1(2*delta) + J_3(2*delta)) / c0
859
  //
860
  // For delta -> 0, c0 -> 1 and c1 -> 0, giving the circular cross-section
861
  // limit.
862

863
  // Compute Bessel function coefficients. openmc::cyl_bessel_j handles
864
  // negative arguments (negative triangularity) via the parity relation
865
  // J_n(-x) = (-1)^n * J_n(x).
866
  double J0_d = cyl_bessel_j(0, delta);
20 ✔
867
  double J2_d = cyl_bessel_j(2, delta);
20 ✔
868
  double J1_2d = cyl_bessel_j(1, 2.0 * delta);
20 ✔
869
  double J3_2d = cyl_bessel_j(3, 2.0 * delta);
20 ✔
870
  double c0 = J0_d + J2_d;
20 ✔
871
  double c1 = (J1_2d + J3_2d) / c0;
20 ✔
872

873
  // Coefficients for the radial polynomial: A*r - B*r^2 - C*r^3
874
  radial_poly_a_ = 1.0 + eps * Dt;
20 ✔
875
  radial_poly_b_ = 0.375 * c1 * eps; // 3/8 * c1 * eps
20 ✔
876
  radial_poly_c_ = 2.0 * eps * Dt;
20 ✔
877

878
  // Build a refined radial grid that retains the user-specified grid points.
879
  // The emission density is interpreted as linear-linear between those points.
880
  constexpr int MIN_SUBINTERVALS = 8;
20 ✔
881
  constexpr double MAX_GRID_SPACING = 1.0e-3;
20 ✔
882
  vector<double> radial_grid {r_over_a_.front()};
20 ✔
883
  vector<double> radial_emission {emission_density_.front()};
20 ✔
884
  for (size_t i = 1; i < r_over_a_.size(); ++i) {
1,070 ✔
885
    double r_lo = r_over_a_[i - 1];
1,050 ✔
886
    double r_hi = r_over_a_[i];
1,050 ✔
887
    double s_lo = emission_density_[i - 1];
1,050 ✔
888
    double s_hi = emission_density_[i];
1,050 ✔
889
    int n_subintervals = std::max(MIN_SUBINTERVALS,
2,100 ✔
890
      static_cast<int>(std::ceil((r_hi - r_lo) / MAX_GRID_SPACING)));
1,050 ✔
891
    for (int j = 1; j <= n_subintervals; ++j) {
24,050 ✔
892
      double t = static_cast<double>(j) / n_subintervals;
23,000 ✔
893
      radial_grid.push_back(r_lo + t * (r_hi - r_lo));
23,000 ✔
894
      radial_emission.push_back(s_lo + t * (s_hi - s_lo));
23,000 ✔
895
    }
896
  }
897

898
  vector<double> radial_pdf(radial_grid.size());
20 ✔
899
  for (size_t i = 0; i < radial_grid.size(); ++i) {
23,040 ✔
900
    double r = radial_grid[i];
23,020 ✔
901
    // p(r) ~ S(r) * [A*r - B*r^2 - C*r^3]
902
    double geometric_factor =
23,020 ✔
903
      radial_poly_a_ * r - radial_poly_b_ * r * r - radial_poly_c_ * r * r * r;
23,020 ✔
904
    radial_pdf[i] = radial_emission[i] * std::max(0.0, geometric_factor);
46,020 ✔
905
  }
906

907
  // Check that the refined profile contains positive probability mass before
908
  // constructing the normalized tabular distribution.
909
  double total = 0.0;
910
  for (size_t i = 1; i < radial_grid.size(); ++i) {
23,020 ✔
911
    total += 0.5 * (radial_pdf[i - 1] + radial_pdf[i]) *
23,000 ✔
912
             (radial_grid[i] - radial_grid[i - 1]);
23,000 ✔
913
  }
914
  if (total <= 0.0) {
20 !
UNCOV
915
    fatal_error(
×
916
      "TokamakSource: Integrated emission density is zero or negative. "
917
      "Check emission_density profile.");
918
  }
919
  radial_dist_ = make_unique<Tabular>(radial_grid.data(), radial_pdf.data(),
60 ✔
920
    radial_grid.size(), Interpolation::lin_lin);
20 ✔
921

922
  //==========================================================================
923
  // POLOIDAL CDFs (for conditional sampling of alpha given r)
924
  //==========================================================================
925
  // The conditional distribution P(alpha | r) is a mixture:
926
  //   P(alpha | r) ~ sum_k w_k(r) * I_hat_k * p_k(alpha)
927
  // where:
928
  //   - w_k(r) are the "dynamic" Bernstein weight functions (depend on r)
929
  //   - I_hat_k are the "static" normalized integrals (precomputed constants)
930
  //   - p_k(alpha) are the normalized basis distributions (precomputed CDFs)
931
  //
932
  // The static weights I_hat_k = I_k / (2*pi*c0) are:
933
  //   I_hat_0 = 1 + eps*Dt
934
  //   I_hat_1 = 1 + eps*Dt - (3/16)*c1*eps
935
  //   I_hat_2 = 1 - (3/8)*c1*eps
936
  //   I_hat_3 = 1 + eps*Dt
937
  //   I_hat_4 = 1 + (1/2)*eps*Dt - (3/16)*c1*eps
938
  //   I_hat_5 = 1 - eps*Dt - (3/8)*c1*eps
939

940
  // Compute static weights analytically
941
  poloidal_integrals_[0] = 1.0 + eps * Dt;
20 ✔
942
  poloidal_integrals_[1] = 1.0 + eps * Dt - 0.1875 * c1 * eps; // 3/16 = 0.1875
20 ✔
943
  poloidal_integrals_[2] = 1.0 - 0.375 * c1 * eps;             // 3/8 = 0.375
20 ✔
944
  poloidal_integrals_[3] = 1.0 + eps * Dt;
20 ✔
945
  poloidal_integrals_[4] = 1.0 + 0.5 * eps * Dt - 0.1875 * c1 * eps;
20 ✔
946
  poloidal_integrals_[5] = 1.0 - eps * Dt - 0.375 * c1 * eps;
20 ✔
947

948
  // Build the alpha grid on [0, pi] (half domain due to up-down symmetry)
949
  int n_alpha = n_alpha_;
20 ✔
950
  vector<double> alpha_grid(n_alpha);
20 ✔
951
  double dalpha = PI / (n_alpha - 1);
20 ✔
952
  for (int i = 0; i < n_alpha; ++i) {
2,050 ✔
953
    alpha_grid[i] = i * dalpha;
2,030 ✔
954
  }
955

956
  // Compute basis function values g_k(alpha) for tabular distributions
957
  // Using Bernstein form:
958
  //   R_tilde = b0*(1-r)^2 + 2*b1*r*(1-r) + b2*r^2
959
  //   J_tilde = b3*(1-r) + b4*r
960
  // with:
961
  //   b0(alpha) = 1 + eps*Dt
962
  //   b1(alpha) = b0 + (eps/2)*cos(psi),  psi = alpha + delta*sin(alpha)
963
  //   b2(alpha) = 1 + eps*cos(psi)
964
  //   b3(alpha) = cos(delta*sin(alpha))
965
  //               + (delta/4)*(cos(alpha - delta*sin(alpha))
966
  //                          - cos(3*alpha + delta*sin(alpha)))
967
  //   b4(alpha) = b3(alpha) - 2*Dt*cos(alpha)
968

969
  array<vector<double>, N_POLOIDAL_BASIS> basis;
970
  for (int k = 0; k < N_POLOIDAL_BASIS; ++k) {
140 ✔
971
    basis[k].resize(n_alpha);
120 ✔
972
  }
973

974
  for (int i = 0; i < n_alpha; ++i) {
2,050 ✔
975
    double alpha = alpha_grid[i];
2,030 ✔
976
    double sin_alpha = std::sin(alpha);
2,030 ✔
977
    double cos_alpha = std::cos(alpha);
2,030 ✔
978
    double delta_sin_alpha = delta * sin_alpha;
2,030 ✔
979
    double psi = alpha + delta_sin_alpha;
2,030 ✔
980
    double cos_psi = std::cos(psi);
2,030 ✔
981

982
    // Bernstein coefficients b0-b4
983
    double b0 = 1.0 + eps * Dt;
2,030 ✔
984
    double b1 = b0 + 0.5 * eps * cos_psi;
2,030 ✔
985
    double b2 = 1.0 + eps * cos_psi;
2,030 ✔
986
    double b3 =
2,030 ✔
987
      std::cos(delta_sin_alpha) + 0.25 * delta *
2,030 ✔
988
                                    (std::cos(alpha - delta_sin_alpha) -
2,030 ✔
989
                                      std::cos(3.0 * alpha + delta_sin_alpha));
2,030 ✔
990
    double b4 = b3 - 2.0 * Dt * cos_alpha;
2,030 ✔
991

992
    // 6 basis functions g_k(alpha) = b_i * b_j
993
    basis[0][i] = b0 * b3; // w0 = (1-r)^3
2,030 ✔
994
    basis[1][i] = b1 * b3; // w1 = 2*r*(1-r)^2
2,030 ✔
995
    basis[2][i] = b2 * b3; // w2 = r^2*(1-r)
2,030 ✔
996
    basis[3][i] = b0 * b4; // w3 = r*(1-r)^2
2,030 ✔
997
    basis[4][i] = b1 * b4; // w4 = 2*r^2*(1-r)
2,030 ✔
998
    basis[5][i] = b2 * b4; // w5 = r^3
2,030 ✔
999
  }
1000

1001
  // Build a linear-linear distribution for each basis function p_k(alpha)
1002
  for (int k = 0; k < N_POLOIDAL_BASIS; ++k) {
140 ✔
1003
    poloidal_dists_[k] = make_unique<Tabular>(
120 ✔
1004
      alpha_grid.data(), basis[k].data(), n_alpha, Interpolation::lin_lin);
240 ✔
1005
  }
1006
}
20 ✔
1007

1008
double TokamakSource::sample_r_over_a(uint64_t* seed) const
800,000 ✔
1009
{
1010
  return radial_dist_->sample(seed).first;
800,000 ✔
1011
}
1012

1013
double TokamakSource::mixture_weight(int k, double r) const
2,724,044 ✔
1014
{
1015
  double s = 1.0 - r;
2,724,044 ✔
1016
  switch (k) {
2,724,044 !
1017
  case 0:
800,000 ✔
1018
    return s * s * s * poloidal_integrals_[0];
800,000 ✔
1019
  case 1:
701,942 ✔
1020
    return 2.0 * r * s * s * poloidal_integrals_[1];
701,942 ✔
1021
  case 2:
79,745 ✔
1022
    return r * r * s * poloidal_integrals_[2];
79,745 ✔
1023
  case 3:
139,973 ✔
1024
    return r * s * s * poloidal_integrals_[3];
139,973 ✔
1025
  case 4:
581,710 ✔
1026
    return 2.0 * r * r * s * poloidal_integrals_[4];
581,710 ✔
1027
  case 5:
420,674 ✔
1028
    return r * r * r * poloidal_integrals_[5];
420,674 ✔
UNCOV
1029
  default:
×
UNCOV
1030
    UNREACHABLE();
×
1031
  }
1032
}
1033

1034
double TokamakSource::sample_poloidal_angle(double r_norm, uint64_t* seed) const
800,000 ✔
1035
{
1036
  // Sample from the conditional distribution P(alpha | r_tilde) using
1037
  // mixture sampling with 6 precomputed basis distributions.
1038
  //
1039
  // The conditional is: P(alpha | r) ~ sum_k w_k(r) * I_hat_k * p_k(alpha)
1040
  // where:
1041
  //   - w_k(r) are the "dynamic" Bernstein weight functions
1042
  //   - I_hat_k are the "static" normalized integrals (precomputed in
1043
  //   poloidal_integrals_)
1044
  //   - p_k(alpha) are the normalized, precomputed basis distributions
1045
  //
1046
  // The normalization sum_k w_k(r) * I_hat_k equals the radial geometric
1047
  // polynomial evaluated at r, which is known analytically.
1048
  //
1049
  // Algorithm:
1050
  // 1. Compute total from analytical normalization
1051
  // 2. Lazily evaluate mixture weights with early exit to select component k
1052
  // 3. Sample alpha from the selected basis distribution
1053

1054
  // Analytical normalization: sum_k w_k(r) * I_hat_k
1055
  double total =
800,000 ✔
1056
    radial_poly_a_ - radial_poly_b_ * r_norm - radial_poly_c_ * r_norm * r_norm;
800,000 ✔
1057
  double xi = prn(seed) * total;
800,000 ✔
1058

1059
  // Sample component via lazy evaluation with early exit
1060
  // Order optimized for peaked emission profiles: 0, 1, 4, 5, 3, 2
1061
  constexpr int order[] = {0, 1, 4, 5, 3, 2};
800,000 ✔
1062
  double cumsum = 0.0;
800,000 ✔
1063
  int component = order[N_POLOIDAL_BASIS - 1];
800,000 ✔
1064
  for (int i = 0; i < N_POLOIDAL_BASIS; ++i) {
2,724,044 !
1065
    cumsum += mixture_weight(order[i], r_norm);
2,724,044 ✔
1066
    if (xi < cumsum) {
2,724,044 ✔
1067
      component = order[i];
1068
      break;
1069
    }
1070
  }
1071

1072
  // Sample alpha from [0, pi]
1073
  double alpha = poloidal_dists_[component]->sample(seed).first;
800,000 ✔
1074

1075
  // Exploit up-down symmetry: randomly flip to [pi, 2*pi] with 50% probability
1076
  // This is equivalent to flipping the sign of Z in the final position
1077
  if (prn(seed) >= 0.5) {
800,000 ✔
1078
    alpha = 2.0 * PI - alpha;
401,970 ✔
1079
  }
1080
  return alpha;
800,000 ✔
1081
}
1082

1083
std::pair<double, double> TokamakSource::sample_energy(
800,000 ✔
1084
  double r_norm, uint64_t* seed) const
1085
{
1086
  if (energy_dists_.size() == 1) {
800,000 !
1087
    // Single distribution for all r
1088
    return energy_dists_[0]->sample(seed);
800,000 ✔
1089
  }
1090

1091
  // Multiple distributions: stochastic selection between bracketing r points
1092
  // Find the interval containing r_norm
1093
  size_t i = lower_bound_index(r_over_a_.begin(), r_over_a_.end(), r_norm);
×
1094

1095
  // Handle boundary cases
UNCOV
1096
  if (i >= energy_dists_.size() - 1) {
×
UNCOV
1097
    return energy_dists_.back()->sample(seed);
×
1098
  }
1099

1100
  // Stochastic interpolation: randomly select one of the two bracketing
1101
  // distributions based on distance to each
UNCOV
1102
  double t = (r_norm - r_over_a_[i]) / (r_over_a_[i + 1] - r_over_a_[i]);
×
UNCOV
1103
  size_t idx = (prn(seed) < t) ? i + 1 : i;
×
UNCOV
1104
  return energy_dists_[idx]->sample(seed);
×
1105
}
1106

1107
Position TokamakSource::flux_to_cartesian(
800,000 ✔
1108
  double r, double alpha, double phi) const
1109
{
1110
  // Flux surface parameterization:
1111
  // R = R0 + r*cos(alpha + delta*sin(alpha)) + Delta*(1 - (r/a)^2)
1112
  // Z = kappa * r * sin(alpha)
1113
  // x = R * cos(phi)
1114
  // y = R * sin(phi)
1115
  // z = Z
1116

1117
  double psi = alpha + triangularity_ * std::sin(alpha);
800,000 ✔
1118
  double r_over_a_sq = (r * r) / (minor_radius_ * minor_radius_);
800,000 ✔
1119

1120
  double R =
800,000 ✔
1121
    major_radius_ + r * std::cos(psi) + shafranov_shift_ * (1.0 - r_over_a_sq);
800,000 ✔
1122
  double Z = elongation_ * r * std::sin(alpha);
800,000 ✔
1123

1124
  double x = R * std::cos(phi);
800,000 ✔
1125
  double y = R * std::sin(phi);
800,000 ✔
1126
  double z = Z;
800,000 ✔
1127

1128
  return {x, y, z};
800,000 ✔
1129
}
1130

1131
SourceSite TokamakSource::sample(uint64_t* seed) const
800,000 ✔
1132
{
1133
  SourceSite site;
800,000 ✔
1134
  site.particle = ParticleType::neutron();
800,000 ✔
1135
  site.wgt = 1.0;
800,000 ✔
1136
  site.delayed_group = 0;
800,000 ✔
1137

1138
  // 1. Sample r/a from radial CDF
1139
  double r_norm = sample_r_over_a(seed);
800,000 ✔
1140
  double r = r_norm * minor_radius_;
800,000 ✔
1141

1142
  // 2. Sample poloidal angle from conditional distribution P(alpha|r)
1143
  double alpha = sample_poloidal_angle(r_norm, seed);
800,000 ✔
1144

1145
  // 3. Sample toroidal angle uniformly in [phi_start, phi_start + phi_extent]
1146
  double phi = phi_start_ + phi_extent_ * prn(seed);
800,000 ✔
1147

1148
  // 4. Convert to Cartesian coordinates
1149
  site.r = flux_to_cartesian(r, alpha, phi);
800,000 ✔
1150

1151
  // 4a. Apply vertical shift if non-zero
1152
  if (vertical_shift_ != 0.0) {
800,000 ✔
1153
    site.r.z += vertical_shift_;
100,000 ✔
1154
  }
1155

1156
  // 5. Sample isotropic direction
1157
  site.u = angle_->sample(seed).first;
800,000 ✔
1158

1159
  // 6. Sample energy from distribution(s), applying the importance weight so
1160
  // that biased distributions are handled correctly
1161
  auto [E, E_wgt] = sample_energy(r_norm, seed);
800,000 ✔
1162
  site.E = E;
800,000 ✔
1163

1164
  // 7. Sample particle creation time
1165
  auto [time, time_wgt] = time_->sample(seed);
800,000 ✔
1166
  site.time = time;
800,000 ✔
1167

1168
  site.wgt *= E_wgt * time_wgt;
800,000 ✔
1169

1170
  return site;
800,000 ✔
1171
}
1172

1173
//==============================================================================
1174
// StellaratorSource implementation
1175
//==============================================================================
1176

1177
namespace {
1178

1179
//! Maximum over t in [0, 1] of the quadratic through q(0)=f0, q(1/2)=fh,
1180
//! q(1)=f1. Exact, since the sampled density is quadratic in the radial
1181
//! interpolation parameter within a bin.
1182
double quad_max01(double f0, double fh, double f1)
1,966,080 ✔
1183
{
1184
  double c = 2.0 * (f0 + f1 - 2.0 * fh);
1,966,080 ✔
1185
  double b = f1 - f0 - c;
1,966,080 ✔
1186
  double m = std::max(f0, f1);
1,966,080 ✔
1187
  if (c < 0.0) {
1,966,080 ✔
1188
    double t_star = -b / (2.0 * c);
972,160 ✔
1189
    if (t_star > 0.0 && t_star < 1.0) {
972,160 !
UNCOV
1190
      m = std::max(m, f0 + t_star * (b + t_star * c));
×
1191
    }
1192
  }
1193
  return m;
1,966,080 ✔
1194
}
1195

1196
// Bernstein basis functions are nonnegative and sum to one on [0,1], so the
1197
// largest Bernstein coefficient bounds the cubic for rejection sampling.
1198
double cubic_rejection_bound(const array<double, 4>& c)
240 ✔
1199
{
1200
  double bound = std::max({c[0], c[0] + c[1] / 3,
240 ✔
1201
    c[0] + 2 * c[1] / 3 + c[2] / 3, c[0] + c[1] + c[2] + c[3]});
240 ✔
1202
  // Add a small relative margin for rounding in the bound and PDF evaluation.
1203
  return bound * (1.0 + 1.e-12);
240 ✔
1204
}
1205

1206
} // namespace
1207

1208
StellaratorSource::StellaratorSource(pugi::xml_node node) : Source(node)
15 ✔
1209
{
1210
  // Read number of field periods
1211
  if (check_for_node(node, "num_field_periods")) {
15 !
1212
    num_field_periods_ = std::stoi(get_node_value(node, "num_field_periods"));
30 ✔
1213
  } else {
UNCOV
1214
    num_field_periods_ = 1;
×
1215
  }
1216

1217
  // Read radial grid and emission profile
1218
  rho_ = get_node_array<double>(node, "rho");
15 ✔
1219
  emission_density_ = get_node_array<double>(node, "emission_density");
15 ✔
1220

1221
  // Read Fourier mode numbers and coefficient tables
1222
  mode_m_ = get_node_array<int>(node, "mode_m");
15 ✔
1223
  mode_n_ = get_node_array<int>(node, "mode_n");
15 ✔
1224
  rmnc_ = get_node_array<double>(node, "rmnc");
15 ✔
1225
  zmns_ = get_node_array<double>(node, "zmns");
15 ✔
1226
  if (check_for_node(node, "rmns") || check_for_node(node, "zmnc")) {
15 !
UNCOV
1227
    if (!check_for_node(node, "rmns") || !check_for_node(node, "zmnc")) {
×
UNCOV
1228
      fatal_error("StellaratorSource: rmns and zmnc must both be given for "
×
1229
                  "non-stellarator-symmetric equilibria.");
1230
    }
UNCOV
1231
    rmns_ = get_node_array<double>(node, "rmns");
×
UNCOV
1232
    zmnc_ = get_node_array<double>(node, "zmnc");
×
UNCOV
1233
    asym_ = true;
×
1234
  }
1235

1236
  // Read energy distribution(s)
1237
  for (auto energy_node : node.children("energy")) {
30 ✔
1238
    energy_dists_.push_back(distribution_from_xml(energy_node));
30 ✔
1239
  }
1240

1241
  // Read optional time distribution; default to a delta distribution at t=0
1242
  if (check_for_node(node, "time")) {
15 !
UNCOV
1243
    time_ = distribution_from_xml(node.child("time"));
×
1244
  } else {
1245
    double T[] {0.0};
15 ✔
1246
    double p[] {1.0};
15 ✔
1247
    time_ = UPtrDist {new Discrete {T, p, 1}};
15 ✔
1248
  }
1249

1250
  // Validate inputs
1251
  size_t n_rho = rho_.size();
15 !
1252
  size_t n_modes = mode_m_.size();
15 ✔
1253
  if (num_field_periods_ < 1) {
15 !
UNCOV
1254
    fatal_error("StellaratorSource: num_field_periods must be >= 1.");
×
1255
  }
1256
  if (n_rho < 2) {
15 !
1257
    fatal_error(
×
1258
      "StellaratorSource: At least 2 radial points are required for profiles.");
1259
  }
1260
  if (rho_.front() != 0.0) {
15 !
1261
    fatal_error("StellaratorSource: rho must start at 0.");
×
1262
  }
1263
  if (rho_.back() != 1.0) {
15 !
UNCOV
1264
    fatal_error("StellaratorSource: rho must end at 1.");
×
1265
  }
1266
  for (size_t i = 1; i < n_rho; ++i) {
255 ✔
1267
    if (rho_[i] <= rho_[i - 1]) {
240 !
UNCOV
1268
      fatal_error("StellaratorSource: rho must be strictly increasing.");
×
1269
    }
1270
  }
1271
  if (emission_density_.size() != n_rho) {
15 !
UNCOV
1272
    fatal_error("StellaratorSource: emission_density and rho must have the "
×
1273
                "same length.");
1274
  }
1275
  for (double s : emission_density_) {
270 ✔
1276
    if (s < 0.0) {
255 !
UNCOV
1277
      fatal_error(
×
1278
        "StellaratorSource: emission_density values cannot be negative.");
1279
    }
1280
  }
1281
  if (std::none_of(emission_density_.begin(), emission_density_.end(),
15 !
1282
        [](double s) { return s > 0.0; })) {
UNCOV
1283
    fatal_error("StellaratorSource: emission_density must contain at least "
×
1284
                "one positive value.");
1285
  }
1286
  if (n_modes == 0) {
15 !
UNCOV
1287
    fatal_error("StellaratorSource: At least one Fourier mode is required.");
×
1288
  }
1289
  if (mode_n_.size() != n_modes) {
15 !
UNCOV
1290
    fatal_error(
×
1291
      "StellaratorSource: mode_m and mode_n must have the same length.");
1292
  }
1293
  for (int m : mode_m_) {
55 ✔
1294
    if (m < 0) {
40 !
UNCOV
1295
      fatal_error("StellaratorSource: poloidal mode numbers must be >= 0 in "
×
1296
                  "the combined Fourier form cos/sin(m*theta - n*Nfp*zeta).");
1297
    }
1298
  }
1299
  if (rmnc_.size() != n_rho * n_modes || zmns_.size() != n_rho * n_modes) {
15 !
UNCOV
1300
    fatal_error("StellaratorSource: rmnc and zmns must have length "
×
1301
                "len(rho)*len(mode_m).");
1302
  }
1303
  if (asym_ &&
15 !
UNCOV
1304
      (rmns_.size() != n_rho * n_modes || zmnc_.size() != n_rho * n_modes)) {
×
UNCOV
1305
    fatal_error("StellaratorSource: rmns and zmnc must have length "
×
1306
                "len(rho)*len(mode_m).");
1307
  }
1308
  if (energy_dists_.empty()) {
15 !
UNCOV
1309
    fatal_error(
×
1310
      "StellaratorSource: At least one energy distribution is required.");
1311
  }
1312
  if (energy_dists_.size() != 1 && energy_dists_.size() != n_rho) {
15 !
UNCOV
1313
    fatal_error("StellaratorSource: energy distributions must be either 1 "
×
1314
                "(for all rho) or match the number of rho points.");
1315
  }
1316

1317
  // Initialize isotropic angular distribution
1318
  angle_ = UPtrAngle {new Isotropic()};
15 ✔
1319

1320
  precompute_sampling_distributions();
15 ✔
1321
}
15 ✔
1322

1323
void StellaratorSource::precompute_sampling_distributions()
15 ✔
1324
{
1325
  size_t n_rho = rho_.size();
15 ✔
1326
  size_t n_modes = mode_m_.size();
15 ✔
1327
  size_t n_bins = n_rho - 1;
15 ✔
1328

1329
  // Angular quadrature grid. The density R*tau is a trigonometric polynomial
1330
  // of poloidal degree <= 3*m_max and toroidal degree <= 3*n_max per field
1331
  // period, so a uniform trapezoidal product rule with more points than the
1332
  // bandwidth integrates it exactly (discrete Fourier orthogonality). The
1333
  // denser grid below also estimates the angular maximum for rejection
1334
  // sampling; a heuristic safety factor allows for peaks between grid points.
1335
  int m_max = 0;
15 ✔
1336
  int n_max = 0;
15 ✔
1337
  for (size_t k = 0; k < n_modes; ++k) {
55 ✔
1338
    m_max = std::max(m_max, mode_m_[k]);
40 ✔
1339
    n_max = std::max(n_max, std::abs(mode_n_[k]));
50 ✔
1340
  }
1341
  int n_theta = std::max(64, 8 * m_max);
15 !
1342
  int n_zeta = std::max(64, 8 * n_max);
15 !
1343
  size_t n_grid = static_cast<size_t>(n_theta) * n_zeta;
15 ✔
1344

1345
  // Per-mode trigonometric tables on the angle grid (zeta spans one field
1346
  // period; the geometry is periodic with period 2*pi/Nfp)
1347
  double dtheta = 2.0 * PI / n_theta;
15 ✔
1348
  double dzeta = 2.0 * PI / (num_field_periods_ * n_zeta);
15 ✔
1349
  vector<double> cos_mt(n_modes * n_theta), sin_mt(n_modes * n_theta);
15 ✔
1350
  vector<double> cos_nz(n_modes * n_zeta), sin_nz(n_modes * n_zeta);
15 ✔
1351
  for (size_t k = 0; k < n_modes; ++k) {
55 ✔
1352
    for (int i = 0; i < n_theta; ++i) {
2,600 ✔
1353
      double a = mode_m_[k] * i * dtheta;
2,560 ✔
1354
      cos_mt[k * n_theta + i] = std::cos(a);
2,560 ✔
1355
      sin_mt[k * n_theta + i] = std::sin(a);
2,560 ✔
1356
    }
1357
    for (int j = 0; j < n_zeta; ++j) {
2,600 ✔
1358
      double a = mode_n_[k] * num_field_periods_ * j * dzeta;
2,560 ✔
1359
      cos_nz[k * n_zeta + j] = std::cos(a);
2,560 ✔
1360
      sin_nz[k * n_zeta + j] = std::sin(a);
2,560 ✔
1361
    }
1362
  }
1363

1364
  // Keep only adjacent surfaces, reusing the older surface's storage after
1365
  // processing each interval. Each surface is evaluated once.
1366
  vector<double> R_s(2 * n_grid), Z_s(2 * n_grid);
15 ✔
1367
  vector<double> Rt_s(2 * n_grid), Zt_s(2 * n_grid);
15 ✔
1368

1369
  // Evaluate positions and poloidal derivatives at each angular grid point.
1370
  auto evaluate_surface = [&](size_t i) {
255 ✔
1371
    const double* rc = &rmnc_[i * n_modes];
255 !
1372
    const double* zs = &zmns_[i * n_modes];
255 ✔
1373
    const double* rs = asym_ ? &rmns_[i * n_modes] : nullptr;
255 !
1374
    const double* zc = asym_ ? &zmnc_[i * n_modes] : nullptr;
255 !
1375
    for (int it = 0; it < n_theta; ++it) {
16,575 ✔
1376
      for (int jz = 0; jz < n_zeta; ++jz) {
1,060,800 ✔
1377
        double R = 0.0, Z = 0.0, Rt = 0.0, Zt = 0.0;
1378
        for (size_t k = 0; k < n_modes; ++k) {
3,829,760 ✔
1379
          // cos/sin(m*theta - n*Nfp*zeta) via angle-addition from tables
1380
          double cm = cos_mt[k * n_theta + it];
2,785,280 !
1381
          double sm = sin_mt[k * n_theta + it];
2,785,280 ✔
1382
          double cn = cos_nz[k * n_zeta + jz];
2,785,280 ✔
1383
          double sn = sin_nz[k * n_zeta + jz];
2,785,280 ✔
1384
          double c = cm * cn + sm * sn;
2,785,280 ✔
1385
          double s = sm * cn - cm * sn;
2,785,280 ✔
1386
          double m = mode_m_[k];
2,785,280 ✔
1387
          R += rc[k] * c;
2,785,280 ✔
1388
          Rt -= m * rc[k] * s;
2,785,280 ✔
1389
          Z += zs[k] * s;
2,785,280 ✔
1390
          Zt += m * zs[k] * c;
2,785,280 ✔
1391
          if (asym_) {
2,785,280 !
UNCOV
1392
            R += rs[k] * s;
×
UNCOV
1393
            Rt += m * rs[k] * c;
×
UNCOV
1394
            Z += zc[k] * c;
×
UNCOV
1395
            Zt -= m * zc[k] * s;
×
1396
          }
1397
        }
1398
        if (R <= 0.0) {
1,044,480 !
UNCOV
1399
          fatal_error(
×
1400
            "StellaratorSource: flux surfaces reach R <= 0; check the "
1401
            "Fourier coefficients (units should be cm).");
1402
        }
1403
        size_t p = (i % 2) * n_grid + it * n_zeta + jz;
1,044,480 ✔
1404
        R_s[p] = R;
1,044,480 ✔
1405
        Z_s[p] = Z;
1,044,480 ✔
1406
        Rt_s[p] = Rt;
1,044,480 ✔
1407
        Zt_s[p] = Zt;
1,044,480 ✔
1408
      }
1409
    }
1410
  };
270 ✔
1411

1412
  // Accumulate signed integrals and bounds for both orientations. The total
1413
  // volume determines the Jacobian sign after all intervals are processed.
1414
  double quad_wgt = 4.0 * PI * PI / n_grid; // full-torus angular weight
15 ✔
1415
  double v_tot = 0.0;
15 ✔
1416
  vector<double> vp_lo(n_bins), vp_mid(n_bins), vp_hi(n_bins);
15 ✔
1417
  vector<double> negative_envelope(n_bins);
15 ✔
1418
  envelope_.assign(n_bins, 0.0);
15 ✔
1419
  double f_peak = 0.0;
15 ✔
1420
  double f_min = 0.0;
15 ✔
1421
  evaluate_surface(0);
15 ✔
1422
  for (size_t b = 0; b < n_bins; ++b) {
255 ✔
1423
    evaluate_surface(b + 1);
240 ✔
1424
    double inv_drho = 1.0 / (rho_[b + 1] - rho_[b]);
240 ✔
1425
    size_t lo = (b % 2) * n_grid;
240 ✔
1426
    size_t hi = ((b + 1) % 2) * n_grid;
240 ✔
1427
    const double* R0 = &R_s[lo];
240 ✔
1428
    const double* R1 = &R_s[hi];
240 ✔
1429
    const double* Z0 = &Z_s[lo];
240 ✔
1430
    const double* Z1 = &Z_s[hi];
240 ✔
1431
    const double* Rt0 = &Rt_s[lo];
240 ✔
1432
    const double* Rt1 = &Rt_s[hi];
240 ✔
1433
    const double* Zt0 = &Zt_s[lo];
240 ✔
1434
    const double* Zt1 = &Zt_s[hi];
240 ✔
1435
    double s0 = 0.0, sh = 0.0, s1 = 0.0;
240 ✔
1436
    double env = 0.0;
240 ✔
1437
    double negative_env = 0.0;
240 ✔
1438
    for (size_t p = 0; p < n_grid; ++p) {
983,280 ✔
1439
      // Linear interpolation makes radial derivatives constant within a bin.
1440
      // R and the poloidal derivatives are linear, so R*tau is quadratic.
1441
      double Rr = (R1[p] - R0[p]) * inv_drho;
983,040 ✔
1442
      double Zr = (Z1[p] - Z0[p]) * inv_drho;
983,040 ✔
1443
      double f0 = R0[p] * (Rr * Zt0[p] - Rt0[p] * Zr);
983,040 ✔
1444
      double f1 = R1[p] * (Rr * Zt1[p] - Rt1[p] * Zr);
983,040 ✔
1445
      double Rh = 0.5 * (R0[p] + R1[p]);
983,040 ✔
1446
      double Rth = 0.5 * (Rt0[p] + Rt1[p]);
983,040 ✔
1447
      double Zth = 0.5 * (Zt0[p] + Zt1[p]);
983,040 ✔
1448
      double fh = Rh * (Rr * Zth - Rth * Zr);
983,040 ✔
1449
      s0 += f0;
983,040 ✔
1450
      sh += fh;
983,040 ✔
1451
      s1 += f1;
983,040 ✔
1452

1453
      // Endpoints and midpoint determine the radial quadratic at this angle.
1454
      // Bound both signs until the global Jacobian orientation is known.
1455
      env = std::max(env, quad_max01(f0, fh, f1));
983,040 ✔
1456
      negative_env = std::max(negative_env, quad_max01(-f0, -fh, -f1));
983,040 ✔
1457
      f_peak = std::max({f_peak, f0, fh, f1});
983,040 ✔
1458
      f_min = std::min({f_min, f0, fh, f1});
983,040 ✔
1459
    }
1460
    vp_lo[b] = s0 * quad_wgt;
240 ✔
1461
    vp_mid[b] = sh * quad_wgt;
240 ✔
1462
    vp_hi[b] = s1 * quad_wgt;
240 ✔
1463
    v_tot += sh * quad_wgt * (rho_[b + 1] - rho_[b]);
240 ✔
1464
    // Heuristic allowance for angular peaks between quadrature points
1465
    envelope_[b] = 1.05 * env;
240 ✔
1466
    negative_envelope[b] = 1.05 * negative_env;
240 ✔
1467
  }
1468
  jacobian_sign_ = (v_tot >= 0.0) ? 1.0 : -1.0;
15 ✔
1469
  if (jacobian_sign_ < 0.0) {
15 ✔
1470
    // Reverse the integrals and extrema to use a positive volume element.
1471
    envelope_.swap(negative_envelope);
5 ✔
1472
    double old_peak = f_peak;
5 ✔
1473
    f_peak = -f_min;
5 ✔
1474
    f_min = -old_peak;
5 ✔
1475
    for (size_t b = 0; b < n_bins; ++b) {
85 ✔
1476
      vp_lo[b] = -vp_lo[b];
80 ✔
1477
      vp_mid[b] = -vp_mid[b];
80 ✔
1478
      vp_hi[b] = -vp_hi[b];
80 ✔
1479
    }
1480
  }
1481
  if (f_min < -1.0e-6 * f_peak) {
15 !
UNCOV
1482
    fatal_error(
×
1483
      "StellaratorSource: the flux-coordinate Jacobian changes sign within "
1484
      "the plasma volume; the flux surfaces self-intersect or the Fourier "
1485
      "coefficient tables are inconsistent.");
1486
  }
1487

1488
  // Integrate S(rho)*V'(rho) independently on each radial interval. Scaling
1489
  // the emission profile by its maximum preserves the normalized distribution.
1490
  double s_max =
15 ✔
1491
    *std::max_element(emission_density_.begin(), emission_density_.end());
15 !
1492
  vector<double> masses(n_bins);
15 ✔
1493
  radial_pdf_.resize(n_bins);
15 ✔
1494
  for (size_t b = 0; b < n_bins; ++b) {
255 ✔
1495
    double h = rho_[b + 1] - rho_[b];
240 !
1496
    double v0 = vp_lo[b];
240 ✔
1497
    double vh = vp_mid[b];
240 ✔
1498
    double v1 = vp_hi[b];
240 ✔
1499

1500
    // Recover V'(t) = a[0] + a[1]*t + a[2]*t^2 for t in [0, 1]. Each
1501
    // interval retains its own one-sided radial derivatives at the endpoints.
1502
    array<double, 3> a {v0, 4 * vh - 3 * v0 - v1, 2 * (v0 + v1 - 2 * vh)};
240 ✔
1503

1504
    // A convex quadratic can have its minimum inside the interval.
1505
    double minimum = std::min(v0, v1);
240 !
1506
    if (a[2] > 0) {
240 ✔
1507
      double t = -a[1] / (2 * a[2]);
105 !
1508
      if (t > 0 && t < 1)
105 !
UNCOV
1509
        minimum = std::min(minimum, a[0] + t * (a[1] + t * a[2]));
×
1510
    }
1511
    if (!std::isfinite(v0 + vh + v1) || minimum < 0)
240 !
UNCOV
1512
      fatal_error("StellaratorSource: invalid differential volume polynomial.");
×
1513
    double s0 = emission_density_[b] / s_max;
240 !
1514
    double s1 = emission_density_[b + 1] / s_max;
240 ✔
1515
    double ds = s1 - s0;
240 ✔
1516
    // Simpson's rule exactly integrates the cubic S(rho)*V'(rho).
1517
    masses[b] = h * ((s0 / 6) * v0 + ((s0 + s1) / 3) * vh + (s1 / 6) * v1);
240 ✔
1518

1519
    // Multiply V'(t) by the linear emission profile to form the radial PDF.
1520
    auto& c = radial_pdf_[b];
240 ✔
1521
    c = {s0 * a[0], s0 * a[1] + ds * a[0], s0 * a[2] + ds * a[1], ds * a[2]};
240 !
1522
    double bound = cubic_rejection_bound(c);
240 ✔
1523
    if (bound > 0) {
240 !
1524
      if (!std::isfinite(bound) || !std::isfinite(masses[b]) || masses[b] <= 0)
240 !
UNCOV
1525
        fatal_error("StellaratorSource: invalid radial interval mass.");
×
1526
      // Store the acceptance probability for uniform proposals within the bin.
1527
      for (double& v : c)
1,200 ✔
1528
        v /= bound;
960 ✔
1529
    }
1530
  }
1531
  // Select intervals by integrated probability, including their radial widths.
1532
  radial_bins_.assign({masses.data(), masses.size()});
15 ✔
1533
}
15 ✔
1534

1535
double StellaratorSource::eval_density(int bin, double t, double theta,
600,694 ✔
1536
  double zeta, double* R_out, double* Z_out) const
1537
{
1538
  size_t n_modes = mode_m_.size();
600,694 !
1539
  // Coefficients at the two surfaces bracketing the sampled radial position
1540
  const double* rc0 = &rmnc_[bin * n_modes];
600,694 !
1541
  const double* rc1 = &rmnc_[(bin + 1) * n_modes];
600,694 ✔
1542
  const double* zs0 = &zmns_[bin * n_modes];
600,694 ✔
1543
  const double* zs1 = &zmns_[(bin + 1) * n_modes];
600,694 ✔
1544
  const double* rs0 = asym_ ? &rmns_[bin * n_modes] : nullptr;
600,694 !
1545
  const double* rs1 = asym_ ? &rmns_[(bin + 1) * n_modes] : nullptr;
600,694 !
1546
  const double* zc0 = asym_ ? &zmnc_[bin * n_modes] : nullptr;
600,694 !
1547
  const double* zc1 = asym_ ? &zmnc_[(bin + 1) * n_modes] : nullptr;
600,694 !
1548
  double inv_drho = 1.0 / (rho_[bin + 1] - rho_[bin]);
600,694 ✔
1549

1550
  // Accumulate positions and derivatives; suffixes r and t denote rho and
1551
  // theta.
1552
  double R = 0.0, Rr = 0.0, Rt = 0.0;
600,694 ✔
1553
  double Z = 0.0, Zr = 0.0, Zt = 0.0;
600,694 ✔
1554
  for (size_t k = 0; k < n_modes; ++k) {
2,207,163 ✔
1555
    double a = mode_m_[k] * theta - mode_n_[k] * num_field_periods_ * zeta;
1,606,469 ✔
1556
    double c = std::cos(a);
1,606,469 ✔
1557
    double s = std::sin(a);
1,606,469 ✔
1558
    double m = mode_m_[k];
1,606,469 !
1559

1560
    // Interpolate coefficients in t; their rho derivatives are the surface
1561
    // differences divided by the bin width. Angular derivatives act on the
1562
    // Fourier basis and introduce a factor of m.
1563
    double drc = rc1[k] - rc0[k];
1,606,469 ✔
1564
    double rc = rc0[k] + t * drc;
1,606,469 ✔
1565
    double dzs = zs1[k] - zs0[k];
1,606,469 ✔
1566
    double zs = zs0[k] + t * dzs;
1,606,469 ✔
1567
    R += rc * c;
1,606,469 ✔
1568
    Rr += drc * inv_drho * c;
1,606,469 ✔
1569
    Rt -= m * rc * s;
1,606,469 ✔
1570
    Z += zs * s;
1,606,469 ✔
1571
    Zr += dzs * inv_drho * s;
1,606,469 ✔
1572
    Zt += m * zs * c;
1,606,469 ✔
1573
    if (asym_) {
1,606,469 !
1574
      // Add the sine-R and cosine-Z terms for non-stellarator-symmetric shapes.
UNCOV
1575
      double drs = rs1[k] - rs0[k];
×
UNCOV
1576
      double rs = rs0[k] + t * drs;
×
UNCOV
1577
      double dzc = zc1[k] - zc0[k];
×
UNCOV
1578
      double zc = zc0[k] + t * dzc;
×
UNCOV
1579
      R += rs * s;
×
UNCOV
1580
      Rr += drs * inv_drho * s;
×
UNCOV
1581
      Rt += m * rs * c;
×
UNCOV
1582
      Z += zc * c;
×
UNCOV
1583
      Zr += dzc * inv_drho * c;
×
UNCOV
1584
      Zt -= m * zc * s;
×
1585
    }
1586
  }
1587
  // Reuse the evaluated position when an angular proposal is accepted.
1588
  if (R_out)
600,694 !
1589
    *R_out = R;
600,694 ✔
1590
  if (Z_out)
600,694 !
1591
    *Z_out = Z;
600,694 ✔
1592

1593
  // Since zeta is the cylindrical azimuth, the volume Jacobian is R*tau,
1594
  // with tau = R_rho*Z_theta - R_theta*Z_rho. S(rho) cancels from the
1595
  // conditional angular density; the global sign accounts for handedness.
1596
  return jacobian_sign_ * R * (Rr * Zt - Rt * Zr);
600,694 ✔
1597
}
1598

1599
std::pair<double, double> StellaratorSource::sample_energy(
450,000 ✔
1600
  double rho, uint64_t* seed) const
1601
{
1602
  if (energy_dists_.size() == 1) {
450,000 !
1603
    // Single distribution for all rho
1604
    return energy_dists_[0]->sample(seed);
450,000 ✔
1605
  }
1606

1607
  // Multiple distributions: stochastic selection between bracketing points
UNCOV
1608
  size_t i = lower_bound_index(rho_.begin(), rho_.end(), rho);
×
UNCOV
1609
  if (i >= energy_dists_.size() - 1) {
×
UNCOV
1610
    return energy_dists_.back()->sample(seed);
×
1611
  }
UNCOV
1612
  double t = (rho - rho_[i]) / (rho_[i + 1] - rho_[i]);
×
UNCOV
1613
  size_t idx = (prn(seed) < t) ? i + 1 : i;
×
UNCOV
1614
  return energy_dists_[idx]->sample(seed);
×
1615
}
1616

1617
SourceSite StellaratorSource::sample(uint64_t* seed) const
450,000 ✔
1618
{
1619
  SourceSite site;
450,000 ✔
1620

1621
  // 1. Select a radial bin by its integrated mass
1622
  int bin = radial_bins_.sample(seed);
450,000 ✔
1623
  const auto& c = radial_pdf_[bin];
450,000 ✔
1624

1625
  // 2. Sample uniformly within the radial bin and reject based on the PDF
1626
  double t = prn(seed);
450,000 ✔
1627
  int64_t n_reject = 0;
1628
  while (true) {
487,248 ✔
1629
    double pdf = c[0] + t * (c[1] + t * (c[2] + t * c[3]));
487,248 ✔
1630
    if (prn(seed) < pdf)
487,248 ✔
1631
      break;
1632
    if (++n_reject > MAX_SOURCE_REJECTIONS_PER_SAMPLE) {
37,248 !
UNCOV
1633
      fatal_error("StellaratorSource: exceeded the maximum number of "
×
1634
                  "rejections while sampling within a radial interval.");
1635
    }
1636
    // Retain the selected bin so rejection does not change its probability.
1637
    t = prn(seed);
37,248 ✔
1638
  }
1639
  double rho = rho_[bin] + t * (rho_[bin + 1] - rho_[bin]);
450,000 ✔
1640

1641
  // 3. Rejection-sample (theta, zeta) from p(theta, zeta | rho) ~ R*tau
1642
  double env = envelope_[bin];
450,000 ✔
1643
  double R, Z, zeta;
450,000 ✔
1644
  n_reject = 0;
450,000 ✔
1645
  while (true) {
600,694 ✔
1646
    double theta = uniform_distribution(0.0, 2.0 * PI, seed);
600,694 ✔
1647
    zeta = uniform_distribution(0.0, 2.0 * PI, seed);
600,694 ✔
1648
    double f = eval_density(bin, t, theta, zeta, &R, &Z);
600,694 ✔
1649
    if (prn(seed) * env < f)
600,694 ✔
1650
      break;
1651
    if (++n_reject > MAX_SOURCE_REJECTIONS_PER_SAMPLE) {
150,694 !
UNCOV
1652
      fatal_error("StellaratorSource: exceeded the maximum number of "
×
1653
                  "rejections while sampling the poloidal/toroidal angles.");
1654
    }
1655
  }
1656

1657
  // 4. Convert to Cartesian coordinates (zeta is the cylindrical angle)
1658
  site.r = {R * std::cos(zeta), R * std::sin(zeta), Z};
450,000 ✔
1659

1660
  // 5. Sample isotropic direction
1661
  site.u = angle_->sample(seed).first;
450,000 ✔
1662

1663
  // 6. Sample energy from distribution(s), applying the importance weight
1664
  auto [E, E_wgt] = sample_energy(rho, seed);
450,000 ✔
1665
  site.E = E;
450,000 ✔
1666

1667
  // 7. Sample particle creation time
1668
  auto [time, time_wgt] = time_->sample(seed);
450,000 ✔
1669
  site.time = time;
450,000 ✔
1670

1671
  site.wgt *= E_wgt * time_wgt;
450,000 ✔
1672

1673
  return site;
450,000 ✔
1674
}
1675

1676
//==============================================================================
1677
// Non-member functions
1678
//==============================================================================
1679

1680
void initialize_source()
1,872 ✔
1681
{
1682
  write_message("Initializing source particles...", 5);
1,872 ✔
1683

1684
// Generation source sites from specified distribution in user input
1685
#pragma omp parallel for
1686
  for (int64_t i = 0; i < simulation::work_per_rank; ++i) {
1,407,702 ✔
1687
    // initialize random number seed
1688
    int64_t id = simulation::total_gen * settings::n_particles +
1,405,830 ✔
1689
                 simulation::work_index[mpi::rank] + i + 1;
1,405,830 ✔
1690
    uint64_t seed = init_seed(id, STREAM_SOURCE);
1,405,830 ✔
1691

1692
    // sample external source distribution
1693
    simulation::source_bank[i] = sample_external_source(&seed);
1,405,830 ✔
1694
  }
1695

1696
  // Write out initial source
1697
  if (settings::write_initial_source) {
1,872 !
UNCOV
1698
    write_message("Writing out initial source...", 5);
×
UNCOV
1699
    std::string filename = settings::path_output + "initial_source.h5";
×
UNCOV
1700
    hid_t file_id = file_open(filename, 'w', true);
×
UNCOV
1701
    write_source_bank(file_id, simulation::source_bank, simulation::work_index);
×
UNCOV
1702
    file_close(file_id);
×
UNCOV
1703
  }
×
1704
}
1,872 ✔
1705

1706
SourceSite sample_external_source(uint64_t* seed)
17,651,640 ✔
1707
{
1708
  // Sample from among multiple source distributions
1709
  int i = 0;
17,651,640 ✔
1710
  int n_sources = model::external_sources.size();
17,651,640 ✔
1711
  if (n_sources > 1) {
17,651,640 ✔
1712
    if (settings::uniform_source_sampling) {
1,626,000 ✔
1713
      i = prn(seed) * n_sources;
1,000 ✔
1714
    } else {
1715
      i = model::external_sources_probability.sample(seed);
1,625,000 ✔
1716
    }
1717
  }
1718

1719
  // Sample source site from i-th source distribution
1720
  SourceSite site {model::external_sources[i]->sample_with_constraints(seed)};
17,651,640 ✔
1721

1722
  // For uniform source sampling, multiply the weight by the ratio of the actual
1723
  // probability of sampling source i to the biased probability of sampling
1724
  // source i, which is (strength_i / total_strength) / (1 / n)
1725
  if (n_sources > 1 && settings::uniform_source_sampling) {
17,651,637 ✔
1726
    double total_strength = model::external_sources_probability.integral();
1,000 ✔
1727
    site.wgt *=
2,000 ✔
1728
      model::external_sources[i]->strength() * n_sources / total_strength;
1,000 ✔
1729
  }
1730

1731
  // If running in MG, convert site.E to group
1732
  if (!settings::run_CE) {
17,651,637 ✔
1733
    site.E = lower_bound_index(data::mg.rev_energy_bins_.begin(),
793,650 ✔
1734
      data::mg.rev_energy_bins_.end(), site.E);
1735
    site.E = data::mg.num_energy_groups_ - site.E - 1.;
793,650 ✔
1736
  }
1737

1738
  return site;
17,651,637 ✔
1739
}
1740

1741
void free_memory_source()
4,425 ✔
1742
{
1743
  model::external_sources.clear();
4,425 ✔
1744
  model::adjoint_sources.clear();
4,425 ✔
1745
  reset_source_rejection_counters();
4,425 ✔
1746
}
4,425 ✔
1747

1748
void reset_source_rejection_counters()
8,232 ✔
1749
{
1750
  source_n_accept = 0;
8,232 ✔
1751
  source_n_reject = 0;
8,232 ✔
1752
}
8,232 ✔
1753

1754
//==============================================================================
1755
// C API
1756
//==============================================================================
1757

1758
extern "C" int openmc_sample_external_source(
1,004 ✔
1759
  size_t n, uint64_t* seed, void* sites)
1760
{
1761
  if (!sites || !seed) {
1,004 !
UNCOV
1762
    set_errmsg("Received null pointer.");
×
UNCOV
1763
    return OPENMC_E_INVALID_ARGUMENT;
×
1764
  }
1765

1766
  if (model::external_sources.empty()) {
1,004 !
UNCOV
1767
    set_errmsg("No external sources have been defined.");
×
UNCOV
1768
    return OPENMC_E_OUT_OF_BOUNDS;
×
1769
  }
1770

1771
  auto sites_array = static_cast<SourceSite*>(sites);
1,004 ✔
1772

1773
  // Derive independent per-particle seeds from the base seed so that
1774
  // each iteration has its own RNG state for thread-safe parallel sampling.
1775
  uint64_t base_seed = *seed;
1,004 ✔
1776

1777
#pragma omp parallel for schedule(static)
1778
  for (size_t i = 0; i < n; ++i) {
3,122,364 ✔
1779
    uint64_t particle_seed = init_seed(base_seed + i, STREAM_SOURCE);
3,121,360 ✔
1780
    sites_array[i] = sample_external_source(&particle_seed);
3,121,360 ✔
1781
  }
1782
  return 0;
1783
}
1784

1785
} // namespace openmc
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc