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

openmc-dev / openmc / 30388101133

28 Jul 2026 06:35PM UTC coverage: 81.058% (-0.4%) from 81.454%
30388101133

Pull #3757

github

web-flow
Merge 3afbe2f70 into f3a8ba29f
Pull Request #3757: Implementation of point detectors

18406 of 26815 branches covered (68.64%)

Branch coverage included in aggregate %.

52 of 384 new or added lines in 25 files covered. (13.54%)

3 existing lines in 2 files now uncovered.

60099 of 70035 relevant lines covered (85.81%)

48910319.72 hits per line

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

81.7
/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/tallies/next_event_scoring.h"
43
#include "openmc/tallies/tally_scoring.h"
44
#include "openmc/xml_interface.h"
45

46
namespace openmc {
47

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

51
namespace {
52

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

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

63
} // namespace
64

65
//==============================================================================
66
// Global variables
67
//==============================================================================
68

69
namespace model {
70

71
vector<unique_ptr<Source>> external_sources;
72

73
vector<unique_ptr<Source>> adjoint_sources;
74

75
DiscreteIndex external_sources_probability;
76

77
} // namespace model
78

79
//==============================================================================
80
// Source implementation
81
//==============================================================================
82

83
Source::Source(pugi::xml_node node)
4,644✔
84
{
85
  // Check for source strength
86
  if (check_for_node(node, "strength")) {
4,644✔
87
    strength_ = std::stod(get_node_value(node, "strength"));
8,748✔
88
    if (strength_ < 0.0) {
4,374!
89
      fatal_error("Source strength is negative.");
×
90
    }
91
  }
92

93
  // Check for additional defined constraints
94
  read_constraints(node);
4,644✔
95
}
4,644✔
96

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

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

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

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

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

171
  if (check_for_node(node, "fissionable")) {
4,644✔
172
    only_fissionable_ = get_node_value_bool(node, "fissionable");
679✔
173
  }
174

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

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

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

206
SourceSite Source::sample_with_constraints(uint64_t* seed) const
16,360,369✔
207
{
208
  bool accepted = false;
16,360,369✔
209
  int64_t n_local_reject = 0;
16,360,369✔
210
  SourceSite site {};
16,360,369✔
211

212
  while (!accepted) {
49,692,998✔
213
    // Sample a source site without considering constraints yet
214
    site = this->sample(seed);
16,972,260✔
215

216
    if (constraints_applied()) {
16,972,260✔
217
      accepted = true;
218
    } else {
219
      // Check whether sampled site satisfies constraints
220
      accepted = satisfies_spatial_constraints(site.r) &&
20,930,552✔
221
                 satisfies_energy_constraints(site.E) &&
2,837,660✔
222
                 satisfies_time_constraints(site.time);
1,115,469✔
223
      if (!accepted) {
611,891✔
224
        ++n_local_reject;
611,891✔
225

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

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

242
  // Flush local rejection count, update accept counter, and check overall
243
  // rejection fraction
244
  if (n_local_reject > 0) {
16,360,369✔
245
    source_n_reject += n_local_reject;
8,712✔
246
  }
247
  ++source_n_accept;
16,360,369✔
248
  check_rejection_fraction(source_n_reject, source_n_accept);
16,360,369✔
249

250
  return site;
16,360,365✔
251
}
252

253
bool Source::satisfies_energy_constraints(double E) const
16,375,870✔
254
{
255
  return E > energy_bounds_.first && E < energy_bounds_.second;
16,375,870!
256
}
257

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

263
bool Source::satisfies_spatial_constraints(Position r) const
18,693,072✔
264
{
265
  GeometryState geom_state;
18,693,072✔
266
  geom_state.r() = r;
18,693,072✔
267
  geom_state.u() = {0.0, 0.0, 1.0};
18,693,072✔
268

269
  // Reject particle if it's not in the geometry at all
270
  bool found = exhaustive_find_cell(geom_state);
18,693,072✔
271
  if (!found)
18,693,072✔
272
    return false;
273

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

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

307
  return accepted;
308
}
18,693,072✔
309

310
//==============================================================================
311
// IndependentSource implementation
312
//==============================================================================
313

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

320
IndependentSource::IndependentSource(pugi::xml_node node) : Source(node)
4,460✔
321
{
322
  // Check for particle type
323
  if (check_for_node(node, "particle")) {
4,460✔
324
    auto temp_str = get_node_value(node, "particle", false, true);
4,109✔
325
    particle_ = ParticleType(temp_str);
4,109✔
326
    if (particle_ == ParticleType::photon() ||
4,109✔
327
        particle_ == ParticleType::electron() ||
4,109✔
328
        particle_ == ParticleType::positron()) {
3,986!
329
      settings::photon_transport = true;
123✔
330
    }
331
  }
4,109✔
332
  validate_particle_type(particle_, "IndependentSource");
4,460✔
333

334
  // Check for external source file
335
  if (check_for_node(node, "file")) {
4,460!
336

337
  } else {
338

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

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

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

363
    // Determine external source energy distribution
364
    if (check_for_node(node, "energy")) {
4,460✔
365
      pugi::xml_node node_dist = node.child("energy");
2,277✔
366
      energy_ = distribution_from_xml(node_dist);
2,277✔
367

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

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

398
SourceSite IndependentSource::sample(uint64_t* seed) const
16,283,069✔
399
{
400
  SourceSite site {};
16,283,069✔
401
  site.particle = particle_;
16,283,069✔
402
  double r_wgt = 1.0;
16,283,069✔
403
  double E_wgt = 1.0;
16,283,069✔
404

405
  // Repeat sampling source location until a good site has been accepted
406
  bool accepted = false;
16,283,069✔
407
  int64_t n_local_reject = 0;
16,283,069✔
408

409
  while (!accepted) {
33,253,950✔
410

411
    // Sample spatial distribution
412
    auto [r, r_wgt_temp] = space_->sample(seed);
16,970,881✔
413
    site.r = r;
16,970,881✔
414
    r_wgt = r_wgt_temp;
16,970,881✔
415

416
    // Check if sampled position satisfies spatial constraints
417
    accepted = satisfies_spatial_constraints(site.r);
16,970,881✔
418

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

429
  // Sample angle
430
  auto [u, u_wgt] = angle_->sample(seed);
16,283,069✔
431
  site.u = u;
16,283,069✔
432

433
  site.wgt = r_wgt * u_wgt;
16,283,069✔
434

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

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

464
      // Resample if energy falls above maximum particle energy
465
      if (site.E < data::energy_max[p] &&
30,500,138!
466
          (satisfies_energy_constraints(site.E)))
15,250,069✔
467
        break;
468

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

476
    // Sample particle creation time
477
    auto [time, time_wgt] = time_->sample(seed);
15,250,069✔
478
    site.time = time;
15,250,069✔
479

480
    site.wgt *= (E_wgt * time_wgt);
15,250,069✔
481
  }
482

483
  // Flush local rejection count into global counter
484
  if (n_local_reject > 0) {
16,283,069✔
485
    source_n_reject += n_local_reject;
162,320✔
486
  }
487

488
  return site;
16,283,069✔
489
}
490

491
//==============================================================================
492
// FileSource implementation
493
//==============================================================================
494

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

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

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

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

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

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

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

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

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

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

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

574
  return site;
575
}
576

577
//==============================================================================
578
// CompiledSourceWrapper implementation
579
//==============================================================================
580

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

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

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

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

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

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

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

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

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

641
//==============================================================================
642
// MeshElementSpatial implementation
643
//==============================================================================
644

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

650
//==============================================================================
651
// MeshSource implementation
652
//==============================================================================
653

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

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

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

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

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

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

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

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

707
//==============================================================================
708
// TokamakSource implementation
709
//==============================================================================
710

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1169
  return site;
800,000✔
1170
}
1171

1172
//==============================================================================
1173
// Non-member functions
1174
//==============================================================================
1175

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

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

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

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

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

1215
  // Sample source site from i-th source distribution
1216
  SourceSite site {model::external_sources[i]->sample_with_constraints(seed)};
15,673,979✔
1217

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

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

1234
  if (!model::active_point_tallies.empty()) {
15,673,975!
NEW
1235
    score_point_tally_source(site, i);
×
1236
  }
1237

1238
  return site;
15,673,975✔
1239
}
1240

1241
void free_memory_source()
3,926✔
1242
{
1243
  model::external_sources.clear();
3,926✔
1244
  model::adjoint_sources.clear();
3,926✔
1245
  reset_source_rejection_counters();
3,926✔
1246
}
3,926✔
1247

1248
void reset_source_rejection_counters()
7,329✔
1249
{
1250
  source_n_accept = 0;
7,329✔
1251
  source_n_reject = 0;
7,329✔
1252
}
7,329✔
1253

1254
//==============================================================================
1255
// C API
1256
//==============================================================================
1257

1258
extern "C" int openmc_sample_external_source(
185✔
1259
  size_t n, uint64_t* seed, void* sites)
1260
{
1261
  if (!sites || !seed) {
185!
1262
    set_errmsg("Received null pointer.");
×
1263
    return OPENMC_E_INVALID_ARGUMENT;
×
1264
  }
1265

1266
  if (model::external_sources.empty()) {
185!
1267
    set_errmsg("No external sources have been defined.");
×
1268
    return OPENMC_E_OUT_OF_BOUNDS;
×
1269
  }
1270

1271
  auto sites_array = static_cast<SourceSite*>(sites);
185✔
1272

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

1277
#pragma omp parallel for schedule(static)
1278
  for (size_t i = 0; i < n; ++i) {
1,871,505✔
1279
    uint64_t particle_seed = init_seed(base_seed + i, STREAM_SOURCE);
1,871,320✔
1280
    sites_array[i] = sample_external_source(&particle_seed);
1,871,320✔
1281
  }
1282
  return 0;
1283
}
1284

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