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

openmc-dev / openmc / 30104129963

24 Jul 2026 03:10PM UTC coverage: 81.421% (+0.07%) from 81.354%
30104129963

Pull #3959

github

web-flow
Merge f462e9a89 into 01790598d
Pull Request #3959: Fix coupled external source rate and transfer rate with destination material

18383 of 26615 branches covered (69.07%)

Branch coverage included in aggregate %.

29 of 30 new or added lines in 1 file covered. (96.67%)

997 existing lines in 34 files now uncovered.

60043 of 69707 relevant lines covered (86.14%)

57144361.17 hits per line

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

81.87
/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)
122,456✔
52
{
53
  if (type.is_transportable())
122,456!
54
    return;
122,456✔
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)
17,841✔
82
{
83
  // Check for source strength
84
  if (check_for_node(node, "strength")) {
17,841✔
85
    strength_ = std::stod(get_node_value(node, "strength"));
34,962✔
86
    if (strength_ < 0.0) {
17,481!
UNCOV
87
      fatal_error("Source strength is negative.");
×
88
    }
89
  }
90

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

95
unique_ptr<Source> Source::create(pugi::xml_node node)
17,841✔
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")) {
17,841✔
100
    std::string source_type = get_node_value(node, "type");
17,367✔
101
    if (source_type == "independent") {
17,367✔
102
      return make_unique<IndependentSource>(node);
17,158✔
103
    } else if (source_type == "file") {
209✔
104
      return make_unique<FileSource>(node);
60✔
105
    } else if (source_type == "compiled") {
149✔
106
      return make_unique<CompiledSourceWrapper>(node);
16✔
107
    } else if (source_type == "mesh") {
133✔
108
      return make_unique<MeshSource>(node);
109✔
109
    } else if (source_type == "tokamak") {
24!
110
      return make_unique<TokamakSource>(node);
24✔
111
    } else {
UNCOV
112
      fatal_error(fmt::format("Invalid source type '{}' found.", source_type));
×
113
    }
114
  } else {
17,360✔
115
    // support legacy source format
116
    if (check_for_node(node, "file")) {
474✔
117
      return make_unique<FileSource>(node);
16✔
118
    } else if (check_for_node(node, "library")) {
458!
UNCOV
119
      return make_unique<CompiledSourceWrapper>(node);
×
120
    } else {
121
      return make_unique<IndependentSource>(node);
458✔
122
    }
123
  }
124
}
125

126
void Source::read_constraints(pugi::xml_node node)
17,841✔
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");
17,841✔
132
  if (constraints_node) {
17,841✔
133
    node = constraints_node;
1,184✔
134
  }
135

136
  // Check for domains to reject from
137
  if (check_for_node(node, "domain_type")) {
17,841✔
138
    std::string domain_type = get_node_value(node, "domain_type");
314✔
139
    if (domain_type == "cell") {
314✔
140
      domain_type_ = DomainType::CELL;
80✔
141
    } else if (domain_type == "material") {
234✔
142
      domain_type_ = DomainType::MATERIAL;
34✔
143
    } else if (domain_type == "universe") {
200!
144
      domain_type_ = DomainType::UNIVERSE;
200✔
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");
314✔
151
    domain_ids_.insert(ids.begin(), ids.end());
314✔
152
  }
314✔
153

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

169
  if (check_for_node(node, "fissionable")) {
17,841✔
170
    only_fissionable_ = get_node_value_bool(node, "fissionable");
864✔
171
  }
172

173
  // Check for how to handle rejected particles
174
  if (check_for_node(node, "rejection_strategy")) {
17,841!
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
}
17,841✔
186

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

193
  // Compute fraction of accepted sites and compare against minimum
194
  double fraction = static_cast<double>(n_accept) / n_reject;
717,576✔
195
  if (fraction <= settings::source_rejection_fraction) {
717,576✔
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
19,919,734✔
205
{
206
  bool accepted = false;
19,919,734✔
207
  int64_t n_local_reject = 0;
19,919,734✔
208
  SourceSite site {};
19,919,734✔
209

210
  while (!accepted) {
60,493,056✔
211
    // Sample a source site without considering constraints yet
212
    site = this->sample(seed);
20,653,588✔
213

214
    if (constraints_applied()) {
20,653,588✔
215
      accepted = true;
216
    } else {
217
      // Check whether sampled site satisfies constraints
218
      accepted = satisfies_spatial_constraints(site.r) &&
25,402,738✔
219
                 satisfies_energy_constraints(site.E) &&
3,404,726✔
220
                 satisfies_time_constraints(site.time);
1,338,502✔
221
      if (!accepted) {
733,854✔
222
        ++n_local_reject;
733,854✔
223

224
        // Check per-particle rejection limit
225
        if (n_local_reject >= MAX_SOURCE_REJECTIONS_PER_SAMPLE) {
733,854!
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) {
733,854!
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) {
19,919,734✔
243
    source_n_reject += n_local_reject;
10,461✔
244
  }
245
  ++source_n_accept;
19,919,734✔
246
  check_rejection_fraction(source_n_reject, source_n_accept);
19,919,734✔
247

248
  return site;
19,919,730✔
249
}
250

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

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

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

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

272
  // Check the geometry state against specified domains
273
  bool accepted = true;
22,389,245✔
274
  if (!domain_ids_.empty()) {
22,389,245✔
275
    if (domain_type_ == DomainType::MATERIAL) {
1,075,090✔
276
      auto mat_index = geom_state.material();
6,000✔
277
      if (mat_index == MATERIAL_VOID) {
6,000!
278
        accepted = false;
279
      } else {
280
        accepted = contains(domain_ids_, model::materials[mat_index]->id());
12,000✔
281
      }
282
    } else {
283
      for (int i = 0; i < geom_state.n_coord(); i++) {
2,110,216✔
284
        auto id =
1,069,090✔
285
          (domain_type_ == DomainType::CELL)
286
            ? model::cells[geom_state.coord(i).cell()].get()->id_
1,069,090!
UNCOV
287
            : model::universes[geom_state.coord(i).universe()].get()->id_;
×
288
        if ((accepted = contains(domain_ids_, id)))
2,138,180✔
289
          break;
290
      }
291
    }
292
  }
293

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

305
  return accepted;
306
}
22,659,712✔
307

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

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

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

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

335
  } else {
336

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

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

354
    // Determine external source angular distribution
355
    if (check_for_node(node, "angle")) {
17,615✔
356
      angle_ = UnitSphereDistribution::create(node.child("angle"));
1,894✔
357
    } else {
358
      angle_ = UPtrAngle {new Isotropic()};
15,721✔
359
    }
360

361
    // Determine external source energy distribution
362
    if (check_for_node(node, "energy")) {
17,615✔
363
      pugi::xml_node node_dist = node.child("energy");
2,809✔
364
      energy_ = distribution_from_xml(node_dist);
2,809✔
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,809!
369
        if (strength_ != 1.0) {
30!
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();
30✔
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)};
14,806✔
381
    }
382

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

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

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

407
  while (!accepted) {
40,421,172✔
408

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

414
    // Check if sampled position satisfies spatial constraints
415
    accepted = satisfies_spatial_constraints(site.r);
20,593,488✔
416

417
    // Check for rejection
418
    if (!accepted) {
20,593,488✔
419
      ++n_local_reject;
765,804✔
420
      if (n_local_reject >= MAX_SOURCE_REJECTIONS_PER_SAMPLE) {
765,804!
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);
19,827,684✔
429
  site.u = u;
19,827,684✔
430

431
  site.wgt = r_wgt * u_wgt;
19,827,684✔
432

433
  // Sample energy and time for neutron and photon sources
434
  if (settings::solver_type != SolverType::RANDOM_RAY) {
19,827,684✔
435
    // Check for monoenergetic source above maximum particle energy
436
    auto p = particle_.transport_index();
18,587,364✔
437
    auto energy_ptr = dynamic_cast<Discrete*>(energy_.get());
18,587,364!
438
    auto decay_spectrum = dynamic_cast<DecaySpectrum*>(energy_.get());
18,587,364!
439
    if (energy_ptr) {
18,587,364✔
440
      auto energies =
9,994,932✔
441
        tensor::Tensor<double>(energy_ptr->x().data(), energy_ptr->x().size());
9,994,932✔
442
      if ((energies > data::energy_max[p]).any()) {
29,984,796!
UNCOV
443
        fatal_error("Source energy above range of energies of at least "
×
444
                    "one cross section table");
445
      }
446
    }
9,994,932✔
447

448
    while (true) {
18,587,364✔
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) {
18,587,364✔
452
        auto sample = decay_spectrum->sample_with_parent(seed);
39,000✔
453
        site.E = sample.energy;
39,000✔
454
        E_wgt = sample.weight;
39,000✔
455
        site.parent_nuclide = sample.parent_nuclide;
39,000✔
456
      } else {
457
        auto [E, E_wgt_temp] = energy_->sample(seed);
18,548,364✔
458
        site.E = E;
18,548,364✔
459
        E_wgt = E_wgt_temp;
18,548,364✔
460
      }
461

462
      // Resample if energy falls above maximum particle energy
463
      if (site.E < data::energy_max[p] &&
37,174,728!
464
          (satisfies_energy_constraints(site.E)))
18,587,364✔
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);
18,587,364✔
476
    site.time = time;
18,587,364✔
477

478
    site.wgt *= (E_wgt * time_wgt);
18,587,364✔
479
  }
480

481
  // Flush local rejection count into global counter
482
  if (n_local_reject > 0) {
19,827,684✔
483
    source_n_reject += n_local_reject;
193,973✔
484
  }
485

486
  return site;
19,827,684✔
487
}
488

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

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

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

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

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

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

520
    // Check to make sure this is a source file
521
    std::string filetype;
76✔
522
    read_attribute(file_id, "filetype", filetype);
76✔
523
    if (filetype != "source" && filetype != "statepoint") {
76!
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);
76✔
529

530
    // Close file
531
    file_close(file_id);
70✔
532
  }
70✔
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_) {
92,134✔
538
    validate_particle_type(site.particle, "FileSource");
92,048✔
539
    if (site.particle == ParticleType::photon() ||
92,048✔
540
        site.particle == ParticleType::electron() ||
92,048!
541
        site.particle == ParticleType::positron()) {
92,042!
542
      settings::photon_transport = true;
6✔
543
    }
544
  }
545
}
86✔
546

547
SourceSite FileSource::sample(uint64_t* seed) const
162,546✔
548
{
549
  // Sample a particle randomly from list
550
  size_t i_site = sites_.size() * prn(seed);
162,546✔
551
  SourceSite site = sites_[i_site];
162,546✔
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) {
162,546✔
558
    auto it = model::surface_map.find(std::abs(site.surf_id));
66,000✔
559
    if (it != model::surface_map.end()) {
66,000✔
560
      const auto& surf = *model::surfaces[it->second];
64,038✔
561
      if (surf.geom_type() == GeometryType::CSG &&
128,076!
562
          std::abs(surf.evaluate(site.r)) < FP_COINCIDENT) {
64,038✔
563
        int surf_id = std::abs(site.surf_id);
62,070✔
564
        site.surf_id =
124,140✔
565
          (site.u.dot(surf.normal(site.r)) > 0.0) ? surf_id : -surf_id;
62,070✔
566
        return site;
62,070✔
567
      }
568
    }
569
    site.surf_id = SURFACE_NONE;
3,930✔
570
  }
571

572
  return site;
573
}
574

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

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

590
void CompiledSourceWrapper::setup(
16✔
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);
16✔
596
  if (!shared_library_) {
16!
UNCOV
597
    fatal_error("Couldn't open source library " + path);
×
598
  }
599

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

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

607
  // check for any dlsym errors
608
  auto dlsym_error = dlerror();
16✔
609
  if (dlsym_error) {
16!
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);
16✔
618

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

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

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

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

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

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

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

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

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

679
  // Make sure sources use valid particle types
680
  for (const auto& src : sources_) {
12,901✔
681
    validate_particle_type(src->particle_type(), "MeshSource");
25,584✔
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()) {
109!
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);
109✔
693
}
109✔
694

695
SourceSite MeshSource::sample(uint64_t* seed) const
823,678✔
696
{
697
  // Sample a mesh element based on the relative strengths
698
  int32_t element = space_->sample_element_index(seed);
823,678✔
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,647,356!
703
}
704

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

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

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

725
  // Read optional toroidal angle bounds
726
  if (check_for_node(node, "phi_start")) {
24!
727
    phi_start_ = std::stod(get_node_value(node, "phi_start"));
48✔
728
  } else {
UNCOV
729
    phi_start_ = 0.0;
×
730
  }
731
  if (check_for_node(node, "phi_extent")) {
24!
732
    phi_extent_ = std::stod(get_node_value(node, "phi_extent"));
48✔
733
  } else {
UNCOV
734
    phi_extent_ = 2.0 * PI;
×
735
  }
736
  if (check_for_node(node, "n_alpha")) {
24!
737
    n_alpha_ = std::stoi(get_node_value(node, "n_alpha"));
48✔
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");
24✔
744
  emission_density_ = get_node_array<double>(node, "emission_density");
24✔
745

746
  // Read energy distribution(s)
747
  for (auto energy_node : node.children("energy")) {
48✔
748
    energy_dists_.push_back(distribution_from_xml(energy_node));
48✔
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")) {
24!
UNCOV
754
    time_ = distribution_from_xml(node.child("time"));
×
755
  } else {
756
    double T[] {0.0};
24✔
757
    double p[] {1.0};
24✔
758
    time_ = UPtrDist {new Discrete {T, p, 1}};
24✔
759
  }
760

761
  // Validate inputs
762
  if (emission_density_.size() != r_over_a_.size()) {
24!
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) {
24!
UNCOV
767
    fatal_error(
×
768
      "TokamakSource: At least 2 radial points are required for profiles.");
769
  }
770
  if (r_over_a_.front() != 0.0) {
24!
771
    fatal_error("TokamakSource: r_over_a must start at 0.");
×
772
  }
773
  if (r_over_a_.back() != 1.0) {
24!
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,284✔
777
    if (r_over_a_[i] <= r_over_a_[i - 1]) {
1,260!
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,308✔
782
    if (emission_density_[i] < 0.0) {
1,284!
783
      fatal_error("TokamakSource: emission_density values cannot be negative.");
×
784
    }
785
  }
786
  if (major_radius_ <= 0.0) {
24!
787
    fatal_error("TokamakSource: major_radius must be > 0.");
×
788
  }
789
  if (minor_radius_ <= 0.0) {
24!
790
    fatal_error("TokamakSource: minor_radius must be > 0.");
×
791
  }
792
  if (minor_radius_ >= major_radius_) {
24!
UNCOV
793
    fatal_error("TokamakSource: minor_radius must be less than major_radius.");
×
794
  }
795
  if (elongation_ <= 0.0) {
24!
UNCOV
796
    fatal_error("TokamakSource: elongation must be > 0.");
×
797
  }
798
  if (triangularity_ < -1.0 || triangularity_ > 1.0) {
24!
UNCOV
799
    fatal_error("TokamakSource: triangularity must be in the range [-1, 1].");
×
800
  }
801
  if (shafranov_shift_ < 0.0) {
24!
UNCOV
802
    fatal_error("TokamakSource: shafranov_shift must be >= 0.");
×
803
  }
804
  if (shafranov_shift_ >= 0.5 * minor_radius_) {
24!
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) {
24!
UNCOV
809
    fatal_error("TokamakSource: phi_extent must be > 0 and <= 2*pi.");
×
810
  }
811
  if (n_alpha_ <= 2) {
24!
UNCOV
812
    fatal_error("TokamakSource: n_alpha must be > 2.");
×
813
  }
814
  if (n_alpha_ < 51) {
24✔
815
    warning("TokamakSource: n_alpha values below 51 may introduce noticeable "
12✔
816
            "discretization bias in source sampling.");
817
  }
818
  if (energy_dists_.empty()) {
24!
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()) {
24!
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_;
24✔
828
  delta_tilde_ = shafranov_shift_ / minor_radius_;
24✔
829

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

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

836
void TokamakSource::precompute_sampling_distributions()
24✔
837
{
838
  // Use precomputed normalized geometry parameters
839
  double eps = epsilon_;    // Inverse aspect ratio (a/R0)
24✔
840
  double Dt = delta_tilde_; // Normalized Shafranov shift (Delta/a)
24✔
841
  double delta = triangularity_;
24✔
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);
24✔
864
  double J2_d = cyl_bessel_j(2, delta);
24✔
865
  double J1_2d = cyl_bessel_j(1, 2.0 * delta);
24✔
866
  double J3_2d = cyl_bessel_j(3, 2.0 * delta);
24✔
867
  double c0 = J0_d + J2_d;
24✔
868
  double c1 = (J1_2d + J3_2d) / c0;
24✔
869

870
  // Coefficients for the radial polynomial: A*r - B*r^2 - C*r^3
871
  radial_poly_a_ = 1.0 + eps * Dt;
24✔
872
  radial_poly_b_ = 0.375 * c1 * eps; // 3/8 * c1 * eps
24✔
873
  radial_poly_c_ = 2.0 * eps * Dt;
24✔
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;
24✔
878
  constexpr double MAX_GRID_SPACING = 1.0e-3;
24✔
879
  vector<double> radial_grid {r_over_a_.front()};
24✔
880
  vector<double> radial_emission {emission_density_.front()};
24✔
881
  for (size_t i = 1; i < r_over_a_.size(); ++i) {
1,284✔
882
    double r_lo = r_over_a_[i - 1];
1,260✔
883
    double r_hi = r_over_a_[i];
1,260✔
884
    double s_lo = emission_density_[i - 1];
1,260✔
885
    double s_hi = emission_density_[i];
1,260✔
886
    int n_subintervals = std::max(MIN_SUBINTERVALS,
2,520✔
887
      static_cast<int>(std::ceil((r_hi - r_lo) / MAX_GRID_SPACING)));
1,260✔
888
    for (int j = 1; j <= n_subintervals; ++j) {
28,860✔
889
      double t = static_cast<double>(j) / n_subintervals;
27,600✔
890
      radial_grid.push_back(r_lo + t * (r_hi - r_lo));
27,600✔
891
      radial_emission.push_back(s_lo + t * (s_hi - s_lo));
27,600✔
892
    }
893
  }
894

895
  vector<double> radial_pdf(radial_grid.size());
24✔
896
  for (size_t i = 0; i < radial_grid.size(); ++i) {
27,648✔
897
    double r = radial_grid[i];
27,624✔
898
    // p(r) ~ S(r) * [A*r - B*r^2 - C*r^3]
899
    double geometric_factor =
27,624✔
900
      radial_poly_a_ * r - radial_poly_b_ * r * r - radial_poly_c_ * r * r * r;
27,624✔
901
    radial_pdf[i] = radial_emission[i] * std::max(0.0, geometric_factor);
55,224✔
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) {
27,624✔
908
    total += 0.5 * (radial_pdf[i - 1] + radial_pdf[i]) *
27,600✔
909
             (radial_grid[i] - radial_grid[i - 1]);
27,600✔
910
  }
911
  if (total <= 0.0) {
24!
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(),
72✔
917
    radial_grid.size(), Interpolation::lin_lin);
24✔
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;
24✔
939
  poloidal_integrals_[1] = 1.0 + eps * Dt - 0.1875 * c1 * eps; // 3/16 = 0.1875
24✔
940
  poloidal_integrals_[2] = 1.0 - 0.375 * c1 * eps;             // 3/8 = 0.375
24✔
941
  poloidal_integrals_[3] = 1.0 + eps * Dt;
24✔
942
  poloidal_integrals_[4] = 1.0 + 0.5 * eps * Dt - 0.1875 * c1 * eps;
24✔
943
  poloidal_integrals_[5] = 1.0 - eps * Dt - 0.375 * c1 * eps;
24✔
944

945
  // Build the alpha grid on [0, pi] (half domain due to up-down symmetry)
946
  int n_alpha = n_alpha_;
24✔
947
  vector<double> alpha_grid(n_alpha);
24✔
948
  double dalpha = PI / (n_alpha - 1);
24✔
949
  for (int i = 0; i < n_alpha; ++i) {
2,460✔
950
    alpha_grid[i] = i * dalpha;
2,436✔
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) {
168✔
968
    basis[k].resize(n_alpha);
144✔
969
  }
970

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

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

989
    // 6 basis functions g_k(alpha) = b_i * b_j
990
    basis[0][i] = b0 * b3; // w0 = (1-r)^3
2,436✔
991
    basis[1][i] = b1 * b3; // w1 = 2*r*(1-r)^2
2,436✔
992
    basis[2][i] = b2 * b3; // w2 = r^2*(1-r)
2,436✔
993
    basis[3][i] = b0 * b4; // w3 = r*(1-r)^2
2,436✔
994
    basis[4][i] = b1 * b4; // w4 = 2*r^2*(1-r)
2,436✔
995
    basis[5][i] = b2 * b4; // w5 = r^3
2,436✔
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) {
168✔
1000
    poloidal_dists_[k] = make_unique<Tabular>(
144✔
1001
      alpha_grid.data(), basis[k].data(), n_alpha, Interpolation::lin_lin);
288✔
1002
  }
1003
}
24✔
1004

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

1010
double TokamakSource::mixture_weight(int k, double r) const
3,271,629✔
1011
{
1012
  double s = 1.0 - r;
3,271,629✔
1013
  switch (k) {
3,271,629!
1014
  case 0:
960,000✔
1015
    return s * s * s * poloidal_integrals_[0];
960,000✔
1016
  case 1:
842,819✔
1017
    return 2.0 * r * s * s * poloidal_integrals_[1];
842,819✔
1018
  case 2:
96,760✔
1019
    return r * r * s * poloidal_integrals_[2];
96,760✔
1020
  case 3:
168,591✔
1021
    return r * s * s * poloidal_integrals_[3];
168,591✔
1022
  case 4:
698,351✔
1023
    return 2.0 * r * r * s * poloidal_integrals_[4];
698,351✔
1024
  case 5:
505,108✔
1025
    return r * r * r * poloidal_integrals_[5];
505,108✔
UNCOV
1026
  default:
×
UNCOV
1027
    UNREACHABLE();
×
1028
  }
1029
}
1030

1031
double TokamakSource::sample_poloidal_angle(double r_norm, uint64_t* seed) const
960,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 =
960,000✔
1053
    radial_poly_a_ - radial_poly_b_ * r_norm - radial_poly_c_ * r_norm * r_norm;
960,000✔
1054
  double xi = prn(seed) * total;
960,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};
960,000✔
1059
  double cumsum = 0.0;
960,000✔
1060
  int component = order[N_POLOIDAL_BASIS - 1];
960,000✔
1061
  for (int i = 0; i < N_POLOIDAL_BASIS; ++i) {
3,271,629!
1062
    cumsum += mixture_weight(order[i], r_norm);
3,271,629✔
1063
    if (xi < cumsum) {
3,271,629✔
1064
      component = order[i];
1065
      break;
1066
    }
1067
  }
1068

1069
  // Sample alpha from [0, pi]
1070
  double alpha = poloidal_dists_[component]->sample(seed).first;
960,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) {
960,000✔
1075
    alpha = 2.0 * PI - alpha;
480,066✔
1076
  }
1077
  return alpha;
960,000✔
1078
}
1079

1080
std::pair<double, double> TokamakSource::sample_energy(
960,000✔
1081
  double r_norm, uint64_t* seed) const
1082
{
1083
  if (energy_dists_.size() == 1) {
960,000!
1084
    // Single distribution for all r
1085
    return energy_dists_[0]->sample(seed);
960,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(
960,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);
960,000✔
1115
  double r_over_a_sq = (r * r) / (minor_radius_ * minor_radius_);
960,000✔
1116

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

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

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

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

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

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

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

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

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

1153
  // 5. Sample isotropic direction
1154
  site.u = angle_->sample(seed).first;
960,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);
960,000✔
1159
  site.E = E;
960,000✔
1160

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

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

1167
  return site;
960,000✔
1168
}
1169

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

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

1178
// Generation source sites from specified distribution in user input
1179
#pragma omp parallel for
482✔
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) {
2,214!
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
}
2,214✔
1199

1200
SourceSite sample_external_source(uint64_t* seed)
19,096,056✔
1201
{
1202
  // Sample from among multiple source distributions
1203
  int i = 0;
19,096,056✔
1204
  int n_sources = model::external_sources.size();
19,096,056✔
1205
  if (n_sources > 1) {
19,096,056✔
1206
    if (settings::uniform_source_sampling) {
1,950,800✔
1207
      i = prn(seed) * n_sources;
1,200✔
1208
    } else {
1209
      i = model::external_sources_probability.sample(seed);
1,949,600✔
1210
    }
1211
  }
1212

1213
  // Sample source site from i-th source distribution
1214
  SourceSite site {model::external_sources[i]->sample_with_constraints(seed)};
19,096,056✔
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) {
19,096,052✔
1220
    double total_strength = model::external_sources_probability.integral();
1,200✔
1221
    site.wgt *=
2,400✔
1222
      model::external_sources[i]->strength() * n_sources / total_strength;
1,200✔
1223
  }
1224

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

1232
  return site;
19,096,052✔
1233
}
1234

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

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

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

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

1260
  if (model::external_sources.empty()) {
423!
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);
423✔
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;
423✔
1270

1271
#pragma omp parallel for schedule(static)
238✔
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;
238✔
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