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

openmc-dev / openmc / 28814482259

06 Jul 2026 06:35PM UTC coverage: 81.306% (+0.02%) from 81.283%
28814482259

Pull #3999

github

web-flow
Merge 23572ccc9 into 0c6b3fb83
Pull Request #3999: native parametric tokamak source

18305 of 26569 branches covered (68.9%)

Branch coverage included in aggregate %.

404 of 445 new or added lines in 3 files covered. (90.79%)

59767 of 69453 relevant lines covered (86.05%)

48337863.48 hits per line

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

80.0
/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 lower_bound, max, distance
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/xml_interface.h"
42

43
namespace openmc {
44

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

48
namespace {
49

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

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

60
} // namespace
61

62
//==============================================================================
63
// Global variables
64
//==============================================================================
65

66
namespace model {
67

68
vector<unique_ptr<Source>> external_sources;
69

70
vector<unique_ptr<Source>> adjoint_sources;
71

72
DiscreteIndex external_sources_probability;
73

74
} // namespace model
75

76
//==============================================================================
77
// Source implementation
78
//==============================================================================
79

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

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

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

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

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

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

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

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

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

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

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

203
SourceSite Source::sample_with_constraints(uint64_t* seed) const
15,832,248✔
204
{
205
  bool accepted = false;
15,832,248✔
206
  int64_t n_local_reject = 0;
15,832,248✔
207
  SourceSite site {};
15,832,248✔
208

209
  while (!accepted) {
48,103,159✔
210
    // Sample a source site without considering constraints yet
211
    site = this->sample(seed);
16,438,663✔
212

213
    if (constraints_applied()) {
16,438,663✔
214
      accepted = true;
215
    } else {
216
      // Check whether sampled site satisfies constraints
217
      accepted = satisfies_spatial_constraints(site.r) &&
18,275,669✔
218
                 satisfies_energy_constraints(site.E) &&
1,421,883✔
219
                 satisfies_time_constraints(site.time);
410,168✔
220
      if (!accepted) {
606,415✔
221
        ++n_local_reject;
606,415✔
222

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

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

239
  // Flush local rejection count, update accept counter, and check overall
240
  // rejection fraction
241
  if (n_local_reject > 0) {
15,832,248✔
242
    source_n_reject += n_local_reject;
8,684✔
243
  }
244
  ++source_n_accept;
15,832,248✔
245
  check_rejection_fraction(source_n_reject, source_n_accept);
15,832,248✔
246

247
  return site;
15,832,244✔
248
}
249

250
bool Source::satisfies_energy_constraints(double E) const
15,846,939✔
251
{
252
  return E > energy_bounds_.first && E < energy_bounds_.second;
15,846,939!
253
}
254

255
bool Source::satisfies_time_constraints(double time) const
410,168✔
256
{
257
  return time > time_bounds_.first && time < time_bounds_.second;
410,168✔
258
}
259

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

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

271
  // Check the geometry state against specified domains
272
  bool accepted = true;
17,932,585✔
273
  if (!domain_ids_.empty()) {
17,932,585✔
274
    if (domain_type_ == DomainType::MATERIAL) {
1,080,770✔
275
      auto mat_index = geom_state.material();
100,000✔
276
      if (mat_index == MATERIAL_VOID) {
100,000!
277
        accepted = false;
278
      } else {
279
        accepted = contains(domain_ids_, model::materials[mat_index]->id());
200,000✔
280
      }
281
    } else {
282
      for (int i = 0; i < geom_state.n_coord(); i++) {
1,891,976✔
283
        auto id =
980,770✔
284
          (domain_type_ == DomainType::CELL)
285
            ? model::cells[geom_state.coord(i).cell()].get()->id_
980,770!
286
            : model::universes[geom_state.coord(i).universe()].get()->id_;
×
287
        if ((accepted = contains(domain_ids_, id)))
1,961,540✔
288
          break;
289
      }
290
    }
291
  }
292

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

304
  return accepted;
305
}
18,157,705✔
306

307
//==============================================================================
308
// IndependentSource implementation
309
//==============================================================================
310

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

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

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

334
  } else {
335

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

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

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

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

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

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

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

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

406
  while (!accepted) {
33,605,938✔
407

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

413
    // Check if sampled position satisfies spatial constraints
414
    accepted = satisfies_spatial_constraints(site.r);
17,145,990✔
415

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

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

430
  site.wgt = r_wgt * u_wgt;
16,459,948✔
431

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

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

461
      // Resample if energy falls above maximum particle energy
462
      if (site.E < data::energy_max[p] &&
30,853,896!
463
          (satisfies_energy_constraints(site.E)))
15,426,948✔
464
        break;
465

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

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

477
    site.wgt *= (E_wgt * time_wgt);
15,426,948✔
478
  }
479

480
  // Flush local rejection count into global counter
481
  if (n_local_reject > 0) {
16,459,948✔
482
    source_n_reject += n_local_reject;
161,920✔
483
  }
484

485
  return site;
16,459,948✔
486
}
487

488
//==============================================================================
489
// FileSource implementation
490
//==============================================================================
491

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

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

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

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

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

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

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

529
    // Close file
530
    file_close(file_id);
27✔
531
  }
27✔
532

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

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

553
//==============================================================================
554
// CompiledSourceWrapper implementation
555
//==============================================================================
556

557
CompiledSourceWrapper::CompiledSourceWrapper(pugi::xml_node node) : Source(node)
12✔
558
{
559
  // Get shared library path and parameters
560
  auto path = get_node_value(node, "library", false, true);
12✔
561
  std::string parameters;
12✔
562
  if (check_for_node(node, "parameters")) {
12✔
563
    parameters = get_node_value(node, "parameters", false, true);
6✔
564
  }
565
  setup(path, parameters);
12✔
566
}
12✔
567

568
void CompiledSourceWrapper::setup(
12✔
569
  const std::string& path, const std::string& parameters)
570
{
571
#ifdef HAS_DYNAMIC_LINKING
572
  // Open the library
573
  shared_library_ = dlopen(path.c_str(), RTLD_LAZY);
12✔
574
  if (!shared_library_) {
12!
575
    fatal_error("Couldn't open source library " + path);
×
576
  }
577

578
  // reset errors
579
  dlerror();
12✔
580

581
  // get the function to create the custom source from the library
582
  auto create_compiled_source = reinterpret_cast<create_compiled_source_t*>(
12✔
583
    dlsym(shared_library_, "openmc_create_source"));
12✔
584

585
  // check for any dlsym errors
586
  auto dlsym_error = dlerror();
12✔
587
  if (dlsym_error) {
12!
588
    std::string error_msg = fmt::format(
×
589
      "Couldn't open the openmc_create_source symbol: {}", dlsym_error);
×
590
    dlclose(shared_library_);
×
591
    fatal_error(error_msg);
×
592
  }
×
593

594
  // create a pointer to an instance of the custom source
595
  compiled_source_ = create_compiled_source(parameters);
12✔
596

597
#else
598
  fatal_error("Custom source libraries have not yet been implemented for "
599
              "non-POSIX systems");
600
#endif
601
}
12✔
602

603
CompiledSourceWrapper::~CompiledSourceWrapper()
24✔
604
{
605
  // Make sure custom source is cleared before closing shared library
606
  if (compiled_source_.get())
12!
607
    compiled_source_.reset();
12✔
608

609
#ifdef HAS_DYNAMIC_LINKING
610
  dlclose(shared_library_);
12✔
611
#else
612
  fatal_error("Custom source libraries have not yet been implemented for "
613
              "non-POSIX systems");
614
#endif
615
}
24✔
616

617
//==============================================================================
618
// MeshElementSpatial implementation
619
//==============================================================================
620

621
std::pair<Position, double> MeshElementSpatial::sample(uint64_t* seed) const
687,031✔
622
{
623
  return {model::meshes[mesh_index_]->sample_element(elem_index_, seed), 1.0};
687,031✔
624
}
625

626
//==============================================================================
627
// MeshSource implementation
628
//==============================================================================
629

630
MeshSource::MeshSource(pugi::xml_node node) : Source(node)
90✔
631
{
632
  int32_t mesh_id = stoi(get_node_value(node, "mesh"));
180✔
633
  int32_t mesh_idx = model::mesh_map.at(mesh_id);
90✔
634
  const auto& mesh = model::meshes[mesh_idx];
90✔
635

636
  std::vector<double> strengths;
90✔
637
  // read all source distributions and populate strengths vector for MeshSpatial
638
  // object
639
  for (auto source_node : node.children("source")) {
750✔
640
    auto src = Source::create(source_node);
660✔
641
    if (auto ptr = dynamic_cast<IndependentSource*>(src.get())) {
660!
642
      src.release();
660✔
643
      sources_.emplace_back(ptr);
660✔
644
    } else {
645
      fatal_error(
×
646
        "The source assigned to each element must be an IndependentSource.");
647
    }
648
    strengths.push_back(sources_.back()->strength());
660✔
649
  }
660✔
650

651
  // Set spatial distributions for each mesh element
652
  for (int elem_index = 0; elem_index < sources_.size(); ++elem_index) {
750✔
653
    sources_[elem_index]->set_space(
660✔
654
      std::make_unique<MeshElementSpatial>(mesh_idx, elem_index));
1,320✔
655
  }
656

657
  // Make sure sources use valid particle types
658
  for (const auto& src : sources_) {
750✔
659
    validate_particle_type(src->particle_type(), "MeshSource");
1,320✔
660
  }
661

662
  // the number of source distributions should either be one or equal to the
663
  // number of mesh elements
664
  if (sources_.size() > 1 && sources_.size() != mesh->n_bins()) {
90!
665
    fatal_error(fmt::format("Incorrect number of source distributions ({}) for "
×
666
                            "mesh source with {} elements.",
667
      sources_.size(), mesh->n_bins()));
×
668
  }
669

670
  space_ = std::make_unique<MeshSpatial>(mesh_idx, strengths);
90✔
671
}
90✔
672

673
SourceSite MeshSource::sample(uint64_t* seed) const
681,724✔
674
{
675
  // Sample a mesh element based on the relative strengths
676
  int32_t element = space_->sample_element_index(seed);
681,724✔
677

678
  // Sample the distribution for the specific mesh element; note that the
679
  // spatial distribution has been set for each element using MeshElementSpatial
680
  return source(element)->sample_with_constraints(seed);
1,363,448!
681
}
682

683
//==============================================================================
684
// TokamakSource implementation
685
//==============================================================================
686

687
TokamakSource::TokamakSource(pugi::xml_node node) : Source(node)
5✔
688
{
689
  // Read geometry parameters
690
  major_radius_ = std::stod(get_node_value(node, "major_radius"));
10✔
691
  minor_radius_ = std::stod(get_node_value(node, "minor_radius"));
10✔
692
  elongation_ = std::stod(get_node_value(node, "elongation"));
10✔
693
  triangularity_ = std::stod(get_node_value(node, "triangularity"));
10✔
694
  shafranov_shift_ = std::stod(get_node_value(node, "shafranov_shift"));
10✔
695

696
  // Read optional vertical shift
697
  if (check_for_node(node, "vertical_shift")) {
5!
698
    vertical_shift_ = std::stod(get_node_value(node, "vertical_shift"));
10✔
699
  } else {
NEW
700
    vertical_shift_ = 0.0;
×
701
  }
702

703
  // Read optional toroidal angle bounds
704
  if (check_for_node(node, "phi_start")) {
5!
705
    phi_start_ = std::stod(get_node_value(node, "phi_start"));
10✔
706
  } else {
NEW
707
    phi_start_ = 0.0;
×
708
  }
709
  if (check_for_node(node, "phi_extent")) {
5!
710
    phi_extent_ = std::stod(get_node_value(node, "phi_extent"));
10✔
711
  } else {
NEW
712
    phi_extent_ = 2.0 * PI;
×
713
  }
714
  if (check_for_node(node, "n_alpha")) {
5!
715
    n_alpha_ = std::stoi(get_node_value(node, "n_alpha"));
10✔
716
  } else {
NEW
717
    n_alpha_ = 101; // Default
×
718
  }
719

720
  // Read emission profile
721
  r_over_a_ = get_node_array<double>(node, "r_over_a");
5✔
722
  emission_density_ = get_node_array<double>(node, "emission_density");
5✔
723

724
  // Read energy distribution(s)
725
  for (auto energy_node : node.children("energy")) {
10✔
726
    energy_dists_.push_back(distribution_from_xml(energy_node));
10✔
727
  }
728

729
  // Read optional time distribution; default to a delta distribution at t=0
730
  // for the same behavior as IndependentSource
731
  if (check_for_node(node, "time")) {
5!
NEW
732
    time_ = distribution_from_xml(node.child("time"));
×
733
  } else {
734
    double T[] {0.0};
5✔
735
    double p[] {1.0};
5✔
736
    time_ = UPtrDist {new Discrete {T, p, 1}};
5✔
737
  }
738

739
  // Validate inputs
740
  if (emission_density_.size() != r_over_a_.size()) {
5!
NEW
741
    fatal_error("TokamakSource: emission_density and r_over_a must have the "
×
742
                "same length.");
743
  }
744
  if (r_over_a_.size() < 2) {
5!
NEW
745
    fatal_error(
×
746
      "TokamakSource: At least 2 radial points are required for profiles.");
747
  }
748
  if (r_over_a_.front() != 0.0) {
5!
NEW
749
    fatal_error("TokamakSource: r_over_a must start at 0.");
×
750
  }
751
  if (r_over_a_.back() != 1.0) {
5!
NEW
752
    fatal_error("TokamakSource: r_over_a must end at 1.");
×
753
  }
754
  for (size_t i = 1; i < r_over_a_.size(); ++i) {
1,000✔
755
    if (r_over_a_[i] <= r_over_a_[i - 1]) {
995!
NEW
756
      fatal_error("TokamakSource: r_over_a must be strictly increasing.");
×
757
    }
758
  }
759
  for (size_t i = 0; i < emission_density_.size(); ++i) {
1,005✔
760
    if (emission_density_[i] < 0.0) {
1,000!
NEW
761
      fatal_error("TokamakSource: emission_density values cannot be negative.");
×
762
    }
763
  }
764
  if (major_radius_ <= 0.0) {
5!
NEW
765
    fatal_error("TokamakSource: major_radius must be > 0.");
×
766
  }
767
  if (minor_radius_ <= 0.0) {
5!
NEW
768
    fatal_error("TokamakSource: minor_radius must be > 0.");
×
769
  }
770
  if (minor_radius_ >= major_radius_) {
5!
NEW
771
    fatal_error("TokamakSource: minor_radius must be less than major_radius.");
×
772
  }
773
  if (elongation_ <= 0.0) {
5!
NEW
774
    fatal_error("TokamakSource: elongation must be > 0.");
×
775
  }
776
  if (triangularity_ < -1.0 || triangularity_ > 1.0) {
5!
NEW
777
    fatal_error("TokamakSource: triangularity must be in the range [-1, 1].");
×
778
  }
779
  if (shafranov_shift_ < 0.0) {
5!
NEW
780
    fatal_error("TokamakSource: shafranov_shift must be >= 0.");
×
781
  }
782
  if (shafranov_shift_ >= 0.5 * minor_radius_) {
5!
NEW
783
    fatal_error("TokamakSource: shafranov_shift must be less than half the "
×
784
                "minor radius.");
785
  }
786
  if (phi_extent_ <= 0.0 || phi_extent_ > 2.0 * PI) {
5!
NEW
787
    fatal_error("TokamakSource: phi_extent must be > 0 and <= 2*pi.");
×
788
  }
789
  if (n_alpha_ <= 2) {
5!
NEW
790
    fatal_error("TokamakSource: n_alpha must be > 2.");
×
791
  }
792
  if (energy_dists_.empty()) {
5!
NEW
793
    fatal_error("TokamakSource: At least one energy distribution is required.");
×
794
  }
795
  if (energy_dists_.size() != 1 && energy_dists_.size() != r_over_a_.size()) {
5!
NEW
796
    fatal_error("TokamakSource: energy distributions must be either 1 (for all "
×
797
                "r) or match the number of r_over_a points.");
798
  }
799

800
  // Compute normalized geometry parameters
801
  epsilon_ = minor_radius_ / major_radius_;
5✔
802
  delta_tilde_ = shafranov_shift_ / minor_radius_;
5✔
803

804
  // Initialize isotropic angular distribution
805
  angle_ = UPtrAngle {new Isotropic()};
5✔
806

807
  precompute_sampling_cdfs();
5✔
808
}
5✔
809

810
void TokamakSource::precompute_sampling_cdfs()
5✔
811
{
812
  // Use precomputed normalized geometry parameters
813
  double eps = epsilon_;    // Inverse aspect ratio (a/R0)
5✔
814
  double Dt = delta_tilde_; // Normalized Shafranov shift (Delta/a)
5✔
815
  double delta = triangularity_;
5✔
816

817
  //==========================================================================
818
  // RADIAL CDF (computed first since it's simpler and sampled first)
819
  //==========================================================================
820
  // The marginal radial PDF is obtained by analytically integrating the joint
821
  // distribution f(r_tilde, alpha) over alpha. The result is:
822
  //
823
  //   p(r_tilde) ~ S(r_tilde) * [(1 + eps*Dt)*r_tilde
824
  //                              - (3/8)*c1*eps*r_tilde^2
825
  //                              - 2*eps*Dt*r_tilde^3]
826
  //
827
  // where the Bessel function coefficients are:
828
  //   c0 = J_0(delta) + J_2(delta)
829
  //   c1 = (J_1(2*delta) + J_3(2*delta)) / c0
830
  //
831
  // For delta -> 0, c0 -> 1 and c1 -> 0, giving the circular cross-section
832
  // limit.
833

834
  // Compute Bessel function coefficients. openmc::cyl_bessel_j handles
835
  // negative arguments (negative triangularity) via the parity relation
836
  // J_n(-x) = (-1)^n * J_n(x).
837
  double J0_d = cyl_bessel_j(0, delta);
5✔
838
  double J2_d = cyl_bessel_j(2, delta);
5✔
839
  double J1_2d = cyl_bessel_j(1, 2.0 * delta);
5✔
840
  double J3_2d = cyl_bessel_j(3, 2.0 * delta);
5✔
841
  double c0 = J0_d + J2_d;
5✔
842
  double c1 = (J1_2d + J3_2d) / c0;
5✔
843

844
  // Coefficients for the radial polynomial: A*r - B*r^2 - C*r^3
845
  radial_poly_a_ = 1.0 + eps * Dt;
5✔
846
  radial_poly_b_ = 0.375 * c1 * eps; // 3/8 * c1 * eps
5✔
847
  radial_poly_c_ = 2.0 * eps * Dt;
5✔
848

849
  // Build the radial CDF on the user-provided r_over_a grid
850
  const size_t n_r = r_over_a_.size();
5✔
851
  radial_cdf_.resize(n_r);
5✔
852
  vector<double> radial_pdf(n_r);
5✔
853

854
  for (size_t i = 0; i < n_r; ++i) {
1,005✔
855
    double r = r_over_a_[i];
1,000✔
856
    double S = emission_density_[i];
1,000✔
857
    // p(r) ~ S(r) * [A*r - B*r^2 - C*r^3]
858
    double geometric_factor =
1,000✔
859
      radial_poly_a_ * r - radial_poly_b_ * r * r - radial_poly_c_ * r * r * r;
1,000✔
860
    radial_pdf[i] = S * std::max(0.0, geometric_factor);
1,995✔
861
  }
862

863
  // Integrate to get CDF using trapezoidal rule on irregular grid
864
  radial_cdf_[0] = 0.0;
5✔
865
  for (size_t i = 1; i < n_r; ++i) {
1,000✔
866
    double dr = r_over_a_[i] - r_over_a_[i - 1];
995✔
867
    double avg = 0.5 * (radial_pdf[i - 1] + radial_pdf[i]);
995✔
868
    radial_cdf_[i] = radial_cdf_[i - 1] + avg * dr;
995✔
869
  }
870

871
  // Normalize CDF
872
  double total = radial_cdf_[n_r - 1];
5!
873
  if (total <= 0.0) {
5!
NEW
874
    fatal_error(
×
875
      "TokamakSource: Integrated emission density is zero or negative. "
876
      "Check emission_density profile.");
877
  }
878
  double inv_total = 1.0 / total;
5✔
879
  for (size_t i = 0; i < n_r; ++i) {
1,005✔
880
    radial_cdf_[i] *= inv_total;
1,000✔
881
  }
882

883
  //==========================================================================
884
  // POLOIDAL CDFs (for conditional sampling of alpha given r)
885
  //==========================================================================
886
  // The conditional distribution P(alpha | r) is a mixture:
887
  //   P(alpha | r) ~ sum_k w_k(r) * I_hat_k * p_k(alpha)
888
  // where:
889
  //   - w_k(r) are the "dynamic" Bernstein weight functions (depend on r)
890
  //   - I_hat_k are the "static" normalized integrals (precomputed constants)
891
  //   - p_k(alpha) are the normalized basis distributions (precomputed CDFs)
892
  //
893
  // The static weights I_hat_k = I_k / (2*pi*c0) are:
894
  //   I_hat_0 = 1 + eps*Dt
895
  //   I_hat_1 = 1 + eps*Dt - (3/16)*c1*eps
896
  //   I_hat_2 = 1 - (3/8)*c1*eps
897
  //   I_hat_3 = 1 + eps*Dt
898
  //   I_hat_4 = 1 + (1/2)*eps*Dt - (3/16)*c1*eps
899
  //   I_hat_5 = 1 - eps*Dt - (3/8)*c1*eps
900

901
  // Compute static weights analytically
902
  poloidal_integrals_[0] = 1.0 + eps * Dt;
5✔
903
  poloidal_integrals_[1] = 1.0 + eps * Dt - 0.1875 * c1 * eps; // 3/16 = 0.1875
5✔
904
  poloidal_integrals_[2] = 1.0 - 0.375 * c1 * eps;             // 3/8 = 0.375
5✔
905
  poloidal_integrals_[3] = 1.0 + eps * Dt;
5✔
906
  poloidal_integrals_[4] = 1.0 + 0.5 * eps * Dt - 0.1875 * c1 * eps;
5✔
907
  poloidal_integrals_[5] = 1.0 - eps * Dt - 0.375 * c1 * eps;
5✔
908

909
  // Build the alpha grid on [0, pi] (half domain due to up-down symmetry)
910
  int n_alpha = n_alpha_;
5✔
911
  poloidal_alpha_grid_.resize(n_alpha);
5✔
912
  double dalpha = PI / (n_alpha - 1);
5✔
913
  for (int i = 0; i < n_alpha; ++i) {
1,010✔
914
    poloidal_alpha_grid_[i] = i * dalpha;
1,005✔
915
  }
916

917
  // Compute basis function values g_k(alpha) for building CDFs
918
  // Using Bernstein form:
919
  //   R_tilde = b0*(1-r)^2 + 2*b1*r*(1-r) + b2*r^2
920
  //   J_tilde = b3*(1-r) + b4*r
921
  // with:
922
  //   b0(alpha) = 1 + eps*Dt
923
  //   b1(alpha) = b0 + (eps/2)*cos(psi),  psi = alpha + delta*sin(alpha)
924
  //   b2(alpha) = 1 + eps*cos(psi)
925
  //   b3(alpha) = cos(delta*sin(alpha))
926
  //               + (delta/4)*(cos(alpha - delta*sin(alpha))
927
  //                          - cos(3*alpha + delta*sin(alpha)))
928
  //   b4(alpha) = b3(alpha) - 2*Dt*cos(alpha)
929

930
  array<vector<double>, N_POLOIDAL_BASIS> basis;
931
  for (int k = 0; k < N_POLOIDAL_BASIS; ++k) {
35✔
932
    basis[k].resize(n_alpha);
30✔
933
    poloidal_cdfs_[k].resize(n_alpha);
30✔
934
  }
935

936
  for (int i = 0; i < n_alpha; ++i) {
1,010✔
937
    double alpha = poloidal_alpha_grid_[i];
1,005✔
938
    double sin_alpha = std::sin(alpha);
1,005✔
939
    double cos_alpha = std::cos(alpha);
1,005✔
940
    double delta_sin_alpha = delta * sin_alpha;
1,005✔
941
    double psi = alpha + delta_sin_alpha;
1,005✔
942
    double cos_psi = std::cos(psi);
1,005✔
943

944
    // Bernstein coefficients b0-b4
945
    double b0 = 1.0 + eps * Dt;
1,005✔
946
    double b1 = b0 + 0.5 * eps * cos_psi;
1,005✔
947
    double b2 = 1.0 + eps * cos_psi;
1,005✔
948
    double b3 =
1,005✔
949
      std::cos(delta_sin_alpha) + 0.25 * delta *
1,005✔
950
                                    (std::cos(alpha - delta_sin_alpha) -
1,005✔
951
                                      std::cos(3.0 * alpha + delta_sin_alpha));
1,005✔
952
    double b4 = b3 - 2.0 * Dt * cos_alpha;
1,005✔
953

954
    // 6 basis functions g_k(alpha) = b_i * b_j
955
    basis[0][i] = b0 * b3; // w0 = (1-r)^3
1,005✔
956
    basis[1][i] = b1 * b3; // w1 = 2*r*(1-r)^2
1,005✔
957
    basis[2][i] = b2 * b3; // w2 = r^2*(1-r)
1,005✔
958
    basis[3][i] = b0 * b4; // w3 = r*(1-r)^2
1,005✔
959
    basis[4][i] = b1 * b4; // w4 = 2*r^2*(1-r)
1,005✔
960
    basis[5][i] = b2 * b4; // w5 = r^3
1,005✔
961
  }
962

963
  // Build normalized CDFs for each basis function p_k(alpha)
964
  for (int k = 0; k < N_POLOIDAL_BASIS; ++k) {
35✔
965
    // Build CDF using trapezoidal integration
966
    poloidal_cdfs_[k][0] = 0.0;
30✔
967
    for (int i = 1; i < n_alpha; ++i) {
6,030✔
968
      double avg = 0.5 * (basis[k][i - 1] + basis[k][i]);
6,000✔
969
      poloidal_cdfs_[k][i] = poloidal_cdfs_[k][i - 1] + avg * dalpha;
6,000✔
970
    }
971

972
    // Normalize CDF to [0, 1]
973
    double norm = poloidal_cdfs_[k][n_alpha - 1];
30!
974
    if (norm > 0.0) {
30!
975
      double inv_norm = 1.0 / norm;
30✔
976
      for (int i = 0; i < n_alpha; ++i) {
6,060✔
977
        poloidal_cdfs_[k][i] *= inv_norm;
6,030✔
978
      }
979
    }
980
  }
981
}
5✔
982

983
double TokamakSource::sample_r_over_a(uint64_t* seed) const
100,000✔
984
{
985
  double xi = prn(seed);
100,000✔
986

987
  // Binary search to find the interval in the CDF
988
  auto it = std::lower_bound(radial_cdf_.begin(), radial_cdf_.end(), xi);
100,000✔
989
  size_t i = std::distance(radial_cdf_.begin(), it);
100,000!
990

991
  if (i == 0)
100,000!
NEW
992
    return r_over_a_.front();
×
993
  if (i >= radial_cdf_.size())
100,000!
NEW
994
    return r_over_a_.back();
×
995

996
  // Linear interpolation within the interval
997
  double cdf_lo = radial_cdf_[i - 1];
100,000✔
998
  double cdf_hi = radial_cdf_[i];
100,000✔
999
  double r_lo = r_over_a_[i - 1];
100,000✔
1000
  double r_hi = r_over_a_[i];
100,000✔
1001

1002
  double t = (xi - cdf_lo) / (cdf_hi - cdf_lo);
100,000✔
1003
  return r_lo + t * (r_hi - r_lo);
100,000✔
1004
}
1005

1006
double TokamakSource::mixture_weight(int k, double r) const
317,172✔
1007
{
1008
  double s = 1.0 - r;
317,172✔
1009
  switch (k) {
317,172!
1010
  case 0:
100,000✔
1011
    return s * s * s * poloidal_integrals_[0];
100,000✔
1012
  case 1:
82,501✔
1013
    return 2.0 * r * s * s * poloidal_integrals_[1];
82,501✔
1014
  case 2:
10,524✔
1015
    return r * r * s * poloidal_integrals_[2];
10,524✔
1016
  case 3:
20,089✔
1017
    return r * s * s * poloidal_integrals_[3];
20,089✔
1018
  case 4:
62,507✔
1019
    return 2.0 * r * r * s * poloidal_integrals_[4];
62,507✔
1020
  case 5:
41,551✔
1021
    return r * r * r * poloidal_integrals_[5];
41,551✔
NEW
1022
  default:
×
NEW
1023
    UNREACHABLE();
×
1024
  }
1025
}
1026

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

1047
  // Analytical normalization: sum_k w_k(r) * I_hat_k
1048
  double total =
100,000✔
1049
    radial_poly_a_ - radial_poly_b_ * r_norm - radial_poly_c_ * r_norm * r_norm;
100,000✔
1050
  double xi = prn(seed) * total;
100,000✔
1051

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

1065
  // Sample alpha from the selected CDF using inverse transform
1066
  double eta = prn(seed);
100,000✔
1067
  const auto& cdf = poloidal_cdfs_[component];
100,000✔
1068
  const size_t n_alpha = poloidal_alpha_grid_.size();
100,000✔
1069

1070
  auto it = std::lower_bound(cdf.begin(), cdf.end(), eta);
100,000✔
1071
  size_t j = std::distance(cdf.begin(), it);
100,000!
1072

1073
  // Sample alpha from [0, pi]
1074
  double alpha;
100,000✔
1075
  if (j == 0) {
100,000!
NEW
1076
    alpha = poloidal_alpha_grid_.front();
×
1077
  } else if (j >= n_alpha) {
100,000!
NEW
1078
    alpha = poloidal_alpha_grid_.back();
×
1079
  } else {
1080
    // Linear interpolation within the bin
1081
    double cdf_lo = cdf[j - 1];
100,000✔
1082
    double cdf_hi = cdf[j];
100,000✔
1083
    double alpha_lo = poloidal_alpha_grid_[j - 1];
100,000✔
1084
    double alpha_hi = poloidal_alpha_grid_[j];
100,000✔
1085
    double t = (eta - cdf_lo) / (cdf_hi - cdf_lo);
100,000✔
1086
    alpha = alpha_lo + t * (alpha_hi - alpha_lo);
100,000✔
1087
  }
1088

1089
  // Exploit up-down symmetry: randomly flip to [pi, 2*pi] with 50% probability
1090
  // This is equivalent to flipping the sign of Z in the final position
1091
  if (prn(seed) >= 0.5) {
100,000✔
1092
    alpha = 2.0 * PI - alpha;
49,603✔
1093
  }
1094
  return alpha;
100,000✔
1095
}
1096

1097
std::pair<double, double> TokamakSource::sample_energy(
100,000✔
1098
  double r_norm, uint64_t* seed) const
1099
{
1100
  if (energy_dists_.size() == 1) {
100,000!
1101
    // Single distribution for all r
1102
    return energy_dists_[0]->sample(seed);
100,000✔
1103
  }
1104

1105
  // Multiple distributions: stochastic selection between bracketing r points
1106
  // Find the interval containing r_norm
NEW
1107
  auto it = std::lower_bound(r_over_a_.begin(), r_over_a_.end(), r_norm);
×
NEW
1108
  size_t i = std::distance(r_over_a_.begin(), it);
×
NEW
1109
  if (i > 0)
×
NEW
1110
    --i;
×
1111

1112
  // Handle boundary cases
NEW
1113
  if (i >= energy_dists_.size() - 1) {
×
NEW
1114
    return energy_dists_.back()->sample(seed);
×
1115
  }
1116

1117
  // Stochastic interpolation: randomly select one of the two bracketing
1118
  // distributions based on distance to each
NEW
1119
  double t = (r_norm - r_over_a_[i]) / (r_over_a_[i + 1] - r_over_a_[i]);
×
NEW
1120
  size_t idx = (prn(seed) < t) ? i + 1 : i;
×
NEW
1121
  return energy_dists_[idx]->sample(seed);
×
1122
}
1123

1124
Position TokamakSource::flux_to_cartesian(
100,000✔
1125
  double r, double alpha, double phi) const
1126
{
1127
  // Flux surface parameterization:
1128
  // R = R0 + r*cos(alpha + delta*sin(alpha)) + Delta*(1 - (r/a)^2)
1129
  // Z = kappa * r * sin(alpha)
1130
  // x = R * cos(phi)
1131
  // y = R * sin(phi)
1132
  // z = Z
1133

1134
  double psi = alpha + triangularity_ * std::sin(alpha);
100,000✔
1135
  double r_over_a_sq = (r * r) / (minor_radius_ * minor_radius_);
100,000✔
1136

1137
  double R =
100,000✔
1138
    major_radius_ + r * std::cos(psi) + shafranov_shift_ * (1.0 - r_over_a_sq);
100,000✔
1139
  double Z = elongation_ * r * std::sin(alpha);
100,000✔
1140

1141
  double x = R * std::cos(phi);
100,000✔
1142
  double y = R * std::sin(phi);
100,000✔
1143
  double z = Z;
100,000✔
1144

1145
  return {x, y, z};
100,000✔
1146
}
1147

1148
SourceSite TokamakSource::sample(uint64_t* seed) const
100,000✔
1149
{
1150
  SourceSite site;
100,000✔
1151
  site.particle = ParticleType::neutron();
100,000✔
1152
  site.wgt = 1.0;
100,000✔
1153
  site.delayed_group = 0;
100,000✔
1154

1155
  // 1. Sample r/a from radial CDF
1156
  double r_norm = sample_r_over_a(seed);
100,000✔
1157
  double r = r_norm * minor_radius_;
100,000✔
1158

1159
  // 2. Sample poloidal angle from conditional distribution P(alpha|r)
1160
  double alpha = sample_poloidal_angle(r_norm, seed);
100,000✔
1161

1162
  // 3. Sample toroidal angle uniformly in [phi_start, phi_start + phi_extent]
1163
  double phi = phi_start_ + phi_extent_ * prn(seed);
100,000✔
1164

1165
  // 4. Convert to Cartesian coordinates
1166
  site.r = flux_to_cartesian(r, alpha, phi);
100,000✔
1167

1168
  // 4a. Apply vertical shift if non-zero
1169
  if (vertical_shift_ != 0.0) {
100,000!
1170
    site.r.z += vertical_shift_;
100,000✔
1171
  }
1172

1173
  // 5. Sample isotropic direction
1174
  site.u = angle_->sample(seed).first;
100,000✔
1175

1176
  // 6. Sample energy from distribution(s), applying the importance weight so
1177
  // that biased distributions are handled correctly
1178
  auto [E, E_wgt] = sample_energy(r_norm, seed);
100,000✔
1179
  site.E = E;
100,000✔
1180

1181
  // 7. Sample particle creation time
1182
  auto [time, time_wgt] = time_->sample(seed);
100,000✔
1183
  site.time = time;
100,000✔
1184

1185
  site.wgt *= E_wgt * time_wgt;
100,000✔
1186

1187
  return site;
100,000✔
1188
}
1189

1190
//==============================================================================
1191
// Non-member functions
1192
//==============================================================================
1193

1194
void initialize_source()
1,722✔
1195
{
1196
  write_message("Initializing source particles...", 5);
1,722✔
1197

1198
// Generation source sites from specified distribution in user input
1199
#pragma omp parallel for
1200
  for (int64_t i = 0; i < simulation::work_per_rank; ++i) {
1,290,187✔
1201
    // initialize random number seed
1202
    int64_t id = simulation::total_gen * settings::n_particles +
1,288,465✔
1203
                 simulation::work_index[mpi::rank] + i + 1;
1,288,465✔
1204
    uint64_t seed = init_seed(id, STREAM_SOURCE);
1,288,465✔
1205

1206
    // sample external source distribution
1207
    simulation::source_bank[i] = sample_external_source(&seed);
1,288,465✔
1208
  }
1209

1210
  // Write out initial source
1211
  if (settings::write_initial_source) {
1,722!
1212
    write_message("Writing out initial source...", 5);
×
1213
    std::string filename = settings::path_output + "initial_source.h5";
×
1214
    hid_t file_id = file_open(filename, 'w', true);
×
1215
    write_source_bank(file_id, simulation::source_bank, simulation::work_index);
×
1216
    file_close(file_id);
×
1217
  }
×
1218
}
1,722✔
1219

1220
SourceSite sample_external_source(uint64_t* seed)
15,150,524✔
1221
{
1222
  // Sample from among multiple source distributions
1223
  int i = 0;
15,150,524✔
1224
  int n_sources = model::external_sources.size();
15,150,524✔
1225
  if (n_sources > 1) {
15,150,524✔
1226
    if (settings::uniform_source_sampling) {
1,616,500✔
1227
      i = prn(seed) * n_sources;
1,000✔
1228
    } else {
1229
      i = model::external_sources_probability.sample(seed);
1,615,500✔
1230
    }
1231
  }
1232

1233
  // Sample source site from i-th source distribution
1234
  SourceSite site {model::external_sources[i]->sample_with_constraints(seed)};
15,150,524✔
1235

1236
  // For uniform source sampling, multiply the weight by the ratio of the actual
1237
  // probability of sampling source i to the biased probability of sampling
1238
  // source i, which is (strength_i / total_strength) / (1 / n)
1239
  if (n_sources > 1 && settings::uniform_source_sampling) {
15,150,520✔
1240
    double total_strength = model::external_sources_probability.integral();
1,000✔
1241
    site.wgt *=
2,000✔
1242
      model::external_sources[i]->strength() * n_sources / total_strength;
1,000✔
1243
  }
1244

1245
  // If running in MG, convert site.E to group
1246
  if (!settings::run_CE) {
15,150,520✔
1247
    site.E = lower_bound_index(data::mg.rev_energy_bins_.begin(),
793,650✔
1248
      data::mg.rev_energy_bins_.end(), site.E);
1249
    site.E = data::mg.num_energy_groups_ - site.E - 1.;
793,650✔
1250
  }
1251

1252
  return site;
15,150,520✔
1253
}
1254

1255
void free_memory_source()
3,865✔
1256
{
1257
  model::external_sources.clear();
3,865✔
1258
  model::adjoint_sources.clear();
3,865✔
1259
  reset_source_rejection_counters();
3,865✔
1260
}
3,865✔
1261

1262
void reset_source_rejection_counters()
7,207✔
1263
{
1264
  source_n_accept = 0;
7,207✔
1265
  source_n_reject = 0;
7,207✔
1266
}
7,207✔
1267

1268
//==============================================================================
1269
// C API
1270
//==============================================================================
1271

1272
extern "C" int openmc_sample_external_source(
170✔
1273
  size_t n, uint64_t* seed, void* sites)
1274
{
1275
  if (!sites || !seed) {
170!
1276
    set_errmsg("Received null pointer.");
×
1277
    return OPENMC_E_INVALID_ARGUMENT;
×
1278
  }
1279

1280
  if (model::external_sources.empty()) {
170!
1281
    set_errmsg("No external sources have been defined.");
×
1282
    return OPENMC_E_OUT_OF_BOUNDS;
×
1283
  }
1284

1285
  auto sites_array = static_cast<SourceSite*>(sites);
170✔
1286

1287
  // Derive independent per-particle seeds from the base seed so that
1288
  // each iteration has its own RNG state for thread-safe parallel sampling.
1289
  uint64_t base_seed = *seed;
170✔
1290

1291
#pragma omp parallel for schedule(static)
1292
  for (size_t i = 0; i < n; ++i) {
1,171,490✔
1293
    uint64_t particle_seed = init_seed(base_seed + i, STREAM_SOURCE);
1,171,320✔
1294
    sites_array[i] = sample_external_source(&particle_seed);
1,171,320✔
1295
  }
1296
  return 0;
1297
}
1298

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

© 2026 Coveralls, Inc