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

openmc-dev / openmc / 30662130446

31 Jul 2026 08:14PM UTC coverage: 81.463% (+0.06%) from 81.4%
30662130446

Pull #3934

github

web-flow
Merge c44d91937 into a8152672b
Pull Request #3934: Fix virtual surface crossing

18514 of 26799 branches covered (69.08%)

Branch coverage included in aggregate %.

23 of 23 new or added lines in 1 file covered. (100.0%)

1004 existing lines in 27 files now uncovered.

60272 of 69915 relevant lines covered (86.21%)

50336961.16 hits per line

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

81.82
/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 <utility>   // for move
10

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

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

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

44
namespace openmc {
45

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

49
namespace {
50

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

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

61
} // namespace
62

63
//==============================================================================
64
// Global variables
65
//==============================================================================
66

67
namespace model {
68

69
vector<unique_ptr<Source>> external_sources;
70

71
vector<unique_ptr<Source>> adjoint_sources;
72

73
DiscreteIndex external_sources_probability;
74

75
} // namespace model
76

77
//==============================================================================
78
// Source implementation
79
//==============================================================================
80

81
Source::Source(pugi::xml_node node)
4,665✔
82
{
83
  // Check for source strength
84
  if (check_for_node(node, "strength")) {
4,665✔
85
    strength_ = std::stod(get_node_value(node, "strength"));
8,790✔
86
    if (strength_ < 0.0) {
4,395!
UNCOV
87
      fatal_error("Source strength is negative.");
×
88
    }
89
  }
90

91
  // Check for additional defined constraints
92
  read_constraints(node);
4,665✔
93
}
4,665✔
94

95
unique_ptr<Source> Source::create(pugi::xml_node node)
4,665✔
96
{
97
  // if the source type is present, use it to determine the type
98
  // of object to create
99
  if (check_for_node(node, "type")) {
4,665✔
100
    std::string source_type = get_node_value(node, "type");
4,302✔
101
    if (source_type == "independent") {
4,302✔
102
      return make_unique<IndependentSource>(node);
4,130✔
103
    } else if (source_type == "file") {
172✔
104
      return make_unique<FileSource>(node);
50✔
105
    } else if (source_type == "compiled") {
122✔
106
      return make_unique<CompiledSourceWrapper>(node);
12✔
107
    } else if (source_type == "mesh") {
110✔
108
      return make_unique<MeshSource>(node);
90✔
109
    } else if (source_type == "tokamak") {
20!
110
      return make_unique<TokamakSource>(node);
20✔
111
    } else {
UNCOV
112
      fatal_error(fmt::format("Invalid source type '{}' found.", source_type));
×
113
    }
114
  } else {
4,297✔
115
    // support legacy source format
116
    if (check_for_node(node, "file")) {
363✔
117
      return make_unique<FileSource>(node);
12✔
118
    } else if (check_for_node(node, "library")) {
351!
UNCOV
119
      return make_unique<CompiledSourceWrapper>(node);
×
120
    } else {
121
      return make_unique<IndependentSource>(node);
351✔
122
    }
123
  }
124
}
125

126
void Source::read_constraints(pugi::xml_node node)
4,665✔
127
{
128
  // Check for constraints node. For backwards compatibility, if no constraints
129
  // node is given, still try searching for domain constraints from top-level
130
  // node.
131
  pugi::xml_node constraints_node = node.child("constraints");
4,665✔
132
  if (constraints_node) {
4,665✔
133
    node = constraints_node;
927✔
134
  }
135

136
  // Check for domains to reject from
137
  if (check_for_node(node, "domain_type")) {
4,665✔
138
    std::string domain_type = get_node_value(node, "domain_type");
243✔
139
    if (domain_type == "cell") {
243✔
140
      domain_type_ = DomainType::CELL;
66✔
141
    } else if (domain_type == "material") {
177✔
142
      domain_type_ = DomainType::MATERIAL;
27✔
143
    } else if (domain_type == "universe") {
150!
144
      domain_type_ = DomainType::UNIVERSE;
150✔
145
    } else {
146
      fatal_error(
×
UNCOV
147
        std::string("Unrecognized domain type for constraint: " + domain_type));
×
148
    }
149

150
    auto ids = get_node_array<int>(node, "domain_ids");
243✔
151
    domain_ids_.insert(ids.begin(), ids.end());
243✔
152
  }
243✔
153

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

169
  if (check_for_node(node, "fissionable")) {
4,665✔
170
    only_fissionable_ = get_node_value_bool(node, "fissionable");
679✔
171
  }
172

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

187
void check_rejection_fraction(int64_t n_reject, int64_t n_accept)
16,510,869✔
188
{
189
  // Don't check unless we've hit a minimum number of total sites rejected
190
  if (n_reject < EXTSRC_REJECT_THRESHOLD)
16,510,869✔
191
    return;
192

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

204
SourceSite Source::sample_with_constraints(uint64_t* seed) const
16,510,869✔
205
{
206
  bool accepted = false;
16,510,869✔
207
  int64_t n_local_reject = 0;
16,510,869✔
208
  SourceSite site {};
16,510,869✔
209

210
  while (!accepted) {
50,144,303✔
211
    // Sample a source site without considering constraints yet
212
    site = this->sample(seed);
17,122,565✔
213

214
    if (constraints_applied()) {
17,122,565✔
215
      accepted = true;
216
    } else {
217
      // Check whether sampled site satisfies constraints
218
      accepted = satisfies_spatial_constraints(site.r) &&
21,080,467✔
219
                 satisfies_energy_constraints(site.E) &&
2,837,431✔
220
                 satisfies_time_constraints(site.time);
1,115,435✔
221
      if (!accepted) {
611,696✔
222
        ++n_local_reject;
611,696✔
223

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

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

240
  // Flush local rejection count, update accept counter, and check overall
241
  // rejection fraction
242
  if (n_local_reject > 0) {
16,510,869✔
243
    source_n_reject += n_local_reject;
8,715✔
244
  }
245
  ++source_n_accept;
16,510,869✔
246
  check_rejection_fraction(source_n_reject, source_n_accept);
16,510,869✔
247

248
  return site;
16,510,865✔
249
}
250

251
bool Source::satisfies_energy_constraints(double E) const
16,526,175✔
252
{
253
  return E > energy_bounds_.first && E < energy_bounds_.second;
16,526,175!
254
}
255

256
bool Source::satisfies_time_constraints(double time) const
1,115,435✔
257
{
258
  return time > time_bounds_.first && time < time_bounds_.second;
1,115,435✔
259
}
260

261
bool Source::satisfies_spatial_constraints(Position r) const
18,843,377✔
262
{
263
  GeometryState geom_state;
18,843,377✔
264
  geom_state.r() = r;
18,843,377✔
265
  geom_state.u() = {0.0, 0.0, 1.0};
18,843,377✔
266

267
  // Reject particle if it's not in the geometry at all
268
  bool found = exhaustive_find_cell(geom_state);
18,843,377✔
269
  if (!found)
18,843,377✔
270
    return false;
271

272
  // Check the geometry state against specified domains
273
  bool accepted = true;
18,618,257✔
274
  if (!domain_ids_.empty()) {
18,618,257✔
275
    if (domain_type_ == DomainType::MATERIAL) {
946,706✔
276
      auto mat_index = geom_state.material();
5,000✔
277
      if (mat_index == MATERIAL_VOID) {
5,000!
278
        accepted = false;
279
      } else {
280
        accepted = contains(domain_ids_, model::materials[mat_index]->id());
10,000✔
281
      }
282
    } else {
283
      for (int i = 0; i < geom_state.n_coord(); i++) {
1,859,348✔
284
        auto id =
941,706✔
285
          (domain_type_ == DomainType::CELL)
286
            ? model::cells[geom_state.coord(i).cell()].get()->id_
941,706!
UNCOV
287
            : model::universes[geom_state.coord(i).universe()].get()->id_;
×
288
        if ((accepted = contains(domain_ids_, id)))
1,883,412✔
289
          break;
290
      }
291
    }
292
  }
293

294
  // Check if spatial site is in fissionable material
295
  if (accepted && only_fissionable_) {
18,618,257✔
296
    // Determine material
297
    auto mat_index = geom_state.material();
491,955✔
298
    if (mat_index == MATERIAL_VOID) {
491,955!
299
      accepted = false;
300
    } else {
301
      accepted = model::materials[mat_index]->fissionable();
491,955✔
302
    }
303
  }
304

305
  return accepted;
306
}
18,843,377✔
307

308
//==============================================================================
309
// IndependentSource implementation
310
//==============================================================================
311

312
IndependentSource::IndependentSource(
985✔
313
  UPtrSpace space, UPtrAngle angle, UPtrDist energy, UPtrDist time)
985✔
314
  : space_ {std::move(space)}, angle_ {std::move(angle)},
985✔
315
    energy_ {std::move(energy)}, time_ {std::move(time)}
985✔
316
{}
985✔
317

318
IndependentSource::IndependentSource(pugi::xml_node node) : Source(node)
4,481✔
319
{
320
  // Check for particle type
321
  if (check_for_node(node, "particle")) {
4,481✔
322
    auto temp_str = get_node_value(node, "particle", false, true);
4,130✔
323
    particle_ = ParticleType(temp_str);
4,130✔
324
    if (particle_ == ParticleType::photon() ||
4,130✔
325
        particle_ == ParticleType::electron() ||
4,130✔
326
        particle_ == ParticleType::positron()) {
4,001!
327
      settings::photon_transport = true;
129✔
328
    }
329
  }
4,130✔
330
  validate_particle_type(particle_, "IndependentSource");
4,481✔
331

332
  // Check for external source file
333
  if (check_for_node(node, "file")) {
4,481!
334

335
  } else {
336

337
    // Spatial distribution for external source
338
    if (check_for_node(node, "space")) {
4,481✔
339
      space_ = SpatialDistribution::create(node.child("space"));
3,440✔
340
    } else {
341
      // If no spatial distribution specified, make it a point source
342
      space_ = UPtrSpace {new SpatialPoint()};
1,041✔
343
    }
344

345
    // For backwards compatibility, check for only fissionable setting on box
346
    // source
347
    auto space_box = dynamic_cast<SpatialBox*>(space_.get());
4,481!
348
    if (space_box) {
4,481✔
349
      if (!only_fissionable_) {
1,840✔
350
        only_fissionable_ = space_box->only_fissionable();
1,161✔
351
      }
352
    }
353

354
    // Determine external source angular distribution
355
    if (check_for_node(node, "angle")) {
4,481✔
356
      angle_ = UnitSphereDistribution::create(node.child("angle"));
1,566✔
357
    } else {
358
      angle_ = UPtrAngle {new Isotropic()};
2,915✔
359
    }
360

361
    // Determine external source energy distribution
362
    if (check_for_node(node, "energy")) {
4,481✔
363
      pugi::xml_node node_dist = node.child("energy");
2,293✔
364
      energy_ = distribution_from_xml(node_dist);
2,293✔
365

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

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

396
SourceSite IndependentSource::sample(uint64_t* seed) const
16,433,569✔
397
{
398
  SourceSite site {};
16,433,569✔
399
  site.particle = particle_;
16,433,569✔
400
  double r_wgt = 1.0;
16,433,569✔
401
  double E_wgt = 1.0;
16,433,569✔
402

403
  // Repeat sampling source location until a good site has been accepted
404
  bool accepted = false;
16,433,569✔
405
  int64_t n_local_reject = 0;
16,433,569✔
406

407
  while (!accepted) {
33,554,950✔
408

409
    // Sample spatial distribution
410
    auto [r, r_wgt_temp] = space_->sample(seed);
17,121,381✔
411
    site.r = r;
17,121,381✔
412
    r_wgt = r_wgt_temp;
17,121,381✔
413

414
    // Check if sampled position satisfies spatial constraints
415
    accepted = satisfies_spatial_constraints(site.r);
17,121,381✔
416

417
    // Check for rejection
418
    if (!accepted) {
17,121,381✔
419
      ++n_local_reject;
687,812✔
420
      if (n_local_reject >= MAX_SOURCE_REJECTIONS_PER_SAMPLE) {
687,812!
UNCOV
421
        fatal_error("Exceeded maximum number of source rejections per "
×
422
                    "sample. Please check your source definition.");
423
      }
424
    }
425
  }
426

427
  // Sample angle
428
  auto [u, u_wgt] = angle_->sample(seed);
16,433,569✔
429
  site.u = u;
16,433,569✔
430

431
  site.wgt = r_wgt * u_wgt;
16,433,569✔
432

433
  // Sample energy and time for neutron and photon sources
434
  if (settings::solver_type != SolverType::RANDOM_RAY) {
16,433,569✔
435
    // Check for monoenergetic source above maximum particle energy
436
    auto p = particle_.transport_index();
15,400,569✔
437
    auto energy_ptr = dynamic_cast<Discrete*>(energy_.get());
15,400,569!
438
    auto decay_spectrum = dynamic_cast<DecaySpectrum*>(energy_.get());
15,400,569!
439
    if (energy_ptr) {
15,400,569✔
440
      auto energies =
8,240,710✔
441
        tensor::Tensor<double>(energy_ptr->x().data(), energy_ptr->x().size());
8,240,710✔
442
      if ((energies > data::energy_max[p]).any()) {
24,722,130!
UNCOV
443
        fatal_error("Source energy above range of energies of at least "
×
444
                    "one cross section table");
445
      }
446
    }
8,240,710✔
447

448
    while (true) {
15,400,569✔
449
      // Sample energy spectrum. For decay photon sources, also get the parent
450
      // nuclide index to store in the source site for tallying purposes.
451
      if (decay_spectrum) {
15,400,569✔
452
        auto sample = decay_spectrum->sample_with_parent(seed);
32,500✔
453
        site.E = sample.energy;
32,500✔
454
        E_wgt = sample.weight;
32,500✔
455
        site.parent_nuclide = sample.parent_nuclide;
32,500✔
456
      } else {
457
        auto [E, E_wgt_temp] = energy_->sample(seed);
15,368,069✔
458
        site.E = E;
15,368,069✔
459
        E_wgt = E_wgt_temp;
15,368,069✔
460
      }
461

462
      // Resample if energy falls above maximum particle energy
463
      if (site.E < data::energy_max[p] &&
30,801,138!
464
          (satisfies_energy_constraints(site.E)))
15,400,569✔
465
        break;
466

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

474
    // Sample particle creation time
475
    auto [time, time_wgt] = time_->sample(seed);
15,400,569✔
476
    site.time = time;
15,400,569✔
477

478
    site.wgt *= (E_wgt * time_wgt);
15,400,569✔
479
  }
480

481
  // Flush local rejection count into global counter
482
  if (n_local_reject > 0) {
16,433,569✔
483
    source_n_reject += n_local_reject;
162,320✔
484
  }
485

486
  return site;
16,433,569✔
487
}
488

489
//==============================================================================
490
// FileSource implementation
491
//==============================================================================
492

493
FileSource::FileSource(pugi::xml_node node) : Source(node)
62✔
494
{
495
  auto path = get_node_value(node, "file", false, true);
62✔
496
  load_sites_from_file(path);
62✔
497
}
57✔
498

499
FileSource::FileSource(const std::string& path)
12✔
500
{
501
  load_sites_from_file(path);
12✔
502
}
12✔
503

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

515
    write_message(6, "Reading source file from {}...", path);
62✔
516

517
    // Open the binary file
518
    hid_t file_id = file_open(path, 'r', true);
62✔
519

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

527
    // Read in the source particles
528
    read_source_bank(file_id, sites_, false);
62✔
529

530
    // Close file
531
    file_close(file_id);
57✔
532
  }
57✔
533

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

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

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

572
  return site;
573
}
574

575
//==============================================================================
576
// CompiledSourceWrapper implementation
577
//==============================================================================
578

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

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

600
  // reset errors
601
  dlerror();
12✔
602

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

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

616
  // create a pointer to an instance of the custom source
617
  compiled_source_ = create_compiled_source(parameters);
12✔
618

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

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

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

639
//==============================================================================
640
// MeshElementSpatial implementation
641
//==============================================================================
642

643
std::pair<Position, double> MeshElementSpatial::sample(uint64_t* seed) const
693,690✔
644
{
645
  return {model::meshes[mesh_index_]->sample_element(elem_index_, seed), 1.0};
693,690✔
646
}
647

648
//==============================================================================
649
// MeshSource implementation
650
//==============================================================================
651

652
MeshSource::MeshSource(pugi::xml_node node) : Source(node)
90✔
653
{
654
  int32_t mesh_id = stoi(get_node_value(node, "mesh"));
180✔
655
  int32_t mesh_idx = model::mesh_map.at(mesh_id);
90✔
656
  const auto& mesh = model::meshes[mesh_idx];
90✔
657

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

673
  // Set spatial distributions for each mesh element
674
  for (int elem_index = 0; elem_index < sources_.size(); ++elem_index) {
750✔
675
    sources_[elem_index]->set_space(
660✔
676
      std::make_unique<MeshElementSpatial>(mesh_idx, elem_index));
1,320✔
677
  }
678

679
  // Make sure sources use valid particle types
680
  for (const auto& src : sources_) {
750✔
681
    validate_particle_type(src->particle_type(), "MeshSource");
1,320✔
682
  }
683

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

692
  space_ = std::make_unique<MeshSpatial>(mesh_idx, strengths);
90✔
693
}
90✔
694

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

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

705
//==============================================================================
706
// TokamakSource implementation
707
//==============================================================================
708

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

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

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

742
  // Read emission profile
743
  r_over_a_ = get_node_array<double>(node, "r_over_a");
20✔
744
  emission_density_ = get_node_array<double>(node, "emission_density");
20✔
745

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

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

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

826
  // Compute normalized geometry parameters
827
  epsilon_ = minor_radius_ / major_radius_;
20✔
828
  delta_tilde_ = shafranov_shift_ / minor_radius_;
20✔
829

830
  // Initialize isotropic angular distribution
831
  angle_ = UPtrAngle {new Isotropic()};
20✔
832

833
  precompute_sampling_distributions();
20✔
834
}
20✔
835

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1010
double TokamakSource::mixture_weight(int k, double r) const
2,726,280✔
1011
{
1012
  double s = 1.0 - r;
2,726,280✔
1013
  switch (k) {
2,726,280!
1014
  case 0:
800,000✔
1015
    return s * s * s * poloidal_integrals_[0];
800,000✔
1016
  case 1:
702,190✔
1017
    return 2.0 * r * s * s * poloidal_integrals_[1];
702,190✔
1018
  case 2:
80,765✔
1019
    return r * r * s * poloidal_integrals_[2];
80,765✔
1020
  case 3:
140,670✔
1021
    return r * s * s * poloidal_integrals_[3];
140,670✔
1022
  case 4:
581,740✔
1023
    return 2.0 * r * r * s * poloidal_integrals_[4];
581,740✔
1024
  case 5:
420,915✔
1025
    return r * r * r * poloidal_integrals_[5];
420,915✔
UNCOV
1026
  default:
×
UNCOV
1027
    UNREACHABLE();
×
1028
  }
1029
}
1030

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

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

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

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

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

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

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

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

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

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

1114
  double psi = alpha + triangularity_ * std::sin(alpha);
800,000✔
1115
  double r_over_a_sq = (r * r) / (minor_radius_ * minor_radius_);
800,000✔
1116

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

1121
  double x = R * std::cos(phi);
800,000✔
1122
  double y = R * std::sin(phi);
800,000✔
1123
  double z = Z;
800,000✔
1124

1125
  return {x, y, z};
800,000✔
1126
}
1127

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

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

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

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

1145
  // 4. Convert to Cartesian coordinates
1146
  site.r = flux_to_cartesian(r, alpha, phi);
800,000✔
1147

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

1153
  // 5. Sample isotropic direction
1154
  site.u = angle_->sample(seed).first;
800,000✔
1155

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

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

1165
  site.wgt *= E_wgt * time_wgt;
800,000✔
1166

1167
  return site;
800,000✔
1168
}
1169

1170
//==============================================================================
1171
// Non-member functions
1172
//==============================================================================
1173

1174
void initialize_source()
1,732✔
1175
{
1176
  write_message("Initializing source particles...", 5);
1,732✔
1177

1178
// Generation source sites from specified distribution in user input
1179
#pragma omp parallel for
1180
  for (int64_t i = 0; i < simulation::work_per_rank; ++i) {
1,291,697✔
1181
    // initialize random number seed
1182
    int64_t id = simulation::total_gen * settings::n_particles +
1,289,965✔
1183
                 simulation::work_index[mpi::rank] + i + 1;
1,289,965✔
1184
    uint64_t seed = init_seed(id, STREAM_SOURCE);
1,289,965✔
1185

1186
    // sample external source distribution
1187
    simulation::source_bank[i] = sample_external_source(&seed);
1,289,965✔
1188
  }
1189

1190
  // Write out initial source
1191
  if (settings::write_initial_source) {
1,732!
UNCOV
1192
    write_message("Writing out initial source...", 5);
×
UNCOV
1193
    std::string filename = settings::path_output + "initial_source.h5";
×
UNCOV
1194
    hid_t file_id = file_open(filename, 'w', true);
×
UNCOV
1195
    write_source_bank(file_id, simulation::source_bank, simulation::work_index);
×
UNCOV
1196
    file_close(file_id);
×
UNCOV
1197
  }
×
1198
}
1,732✔
1199

1200
SourceSite sample_external_source(uint64_t* seed)
15,824,479✔
1201
{
1202
  // Sample from among multiple source distributions
1203
  int i = 0;
15,824,479✔
1204
  int n_sources = model::external_sources.size();
15,824,479✔
1205
  if (n_sources > 1) {
15,824,479✔
1206
    if (settings::uniform_source_sampling) {
1,624,000✔
1207
      i = prn(seed) * n_sources;
1,000✔
1208
    } else {
1209
      i = model::external_sources_probability.sample(seed);
1,623,000✔
1210
    }
1211
  }
1212

1213
  // Sample source site from i-th source distribution
1214
  SourceSite site {model::external_sources[i]->sample_with_constraints(seed)};
15,824,479✔
1215

1216
  // For uniform source sampling, multiply the weight by the ratio of the actual
1217
  // probability of sampling source i to the biased probability of sampling
1218
  // source i, which is (strength_i / total_strength) / (1 / n)
1219
  if (n_sources > 1 && settings::uniform_source_sampling) {
15,824,475✔
1220
    double total_strength = model::external_sources_probability.integral();
1,000✔
1221
    site.wgt *=
2,000✔
1222
      model::external_sources[i]->strength() * n_sources / total_strength;
1,000✔
1223
  }
1224

1225
  // If running in MG, convert site.E to group
1226
  if (!settings::run_CE) {
15,824,475✔
1227
    site.E = lower_bound_index(data::mg.rev_energy_bins_.begin(),
793,650✔
1228
      data::mg.rev_energy_bins_.end(), site.E);
1229
    site.E = data::mg.num_energy_groups_ - site.E - 1.;
793,650✔
1230
  }
1231

1232
  return site;
15,824,475✔
1233
}
1234

1235
void free_memory_source()
3,947✔
1236
{
1237
  model::external_sources.clear();
3,947✔
1238
  model::adjoint_sources.clear();
3,947✔
1239
  reset_source_rejection_counters();
3,947✔
1240
}
3,947✔
1241

1242
void reset_source_rejection_counters()
7,371✔
1243
{
1244
  source_n_accept = 0;
7,371✔
1245
  source_n_reject = 0;
7,371✔
1246
}
7,371✔
1247

1248
//==============================================================================
1249
// C API
1250
//==============================================================================
1251

1252
extern "C" int openmc_sample_external_source(
185✔
1253
  size_t n, uint64_t* seed, void* sites)
1254
{
1255
  if (!sites || !seed) {
185!
UNCOV
1256
    set_errmsg("Received null pointer.");
×
UNCOV
1257
    return OPENMC_E_INVALID_ARGUMENT;
×
1258
  }
1259

1260
  if (model::external_sources.empty()) {
185!
UNCOV
1261
    set_errmsg("No external sources have been defined.");
×
UNCOV
1262
    return OPENMC_E_OUT_OF_BOUNDS;
×
1263
  }
1264

1265
  auto sites_array = static_cast<SourceSite*>(sites);
185✔
1266

1267
  // Derive independent per-particle seeds from the base seed so that
1268
  // each iteration has its own RNG state for thread-safe parallel sampling.
1269
  uint64_t base_seed = *seed;
185✔
1270

1271
#pragma omp parallel for schedule(static)
1272
  for (size_t i = 0; i < n; ++i) {
1,871,505✔
1273
    uint64_t particle_seed = init_seed(base_seed + i, STREAM_SOURCE);
1,871,320✔
1274
    sites_array[i] = sample_external_source(&particle_seed);
1,871,320✔
1275
  }
1276
  return 0;
1277
}
1278

1279
} // 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